576 lines
17 KiB
Svelte
576 lines
17 KiB
Svelte
<script lang="ts">
|
|
import Navbar from '$lib/components/Navbar.svelte';
|
|
import Footer from '$lib/components/Footer.svelte';
|
|
|
|
// API Base - uses env var for local dev, defaults to cockpit for production
|
|
const API_BASE = (import.meta.env.VITE_API_URL || 'https://cockpit.valuecurve.co') + '/api/v1';
|
|
|
|
// State
|
|
let loading = $state(false);
|
|
let error = $state('');
|
|
let metadata: any = $state(null);
|
|
let houseData: any[] = $state([]);
|
|
let statistics: any = $state(null);
|
|
let prediction: any = $state(null);
|
|
|
|
// Filters
|
|
let minPrice = $state(0);
|
|
let maxPrice = $state(8000000);
|
|
let minBedrooms = $state(0);
|
|
let maxBedrooms = $state(10);
|
|
let waterfrontOnly = $state(false);
|
|
let selectedZipcode = $state('');
|
|
|
|
// Prediction inputs
|
|
let predBedrooms = $state(3);
|
|
let predBathrooms = $state(2);
|
|
let predSqft = $state(2000);
|
|
let predAge = $state(10);
|
|
|
|
// Tabs
|
|
let activeTab: 'map' | 'distribution' | 'correlation' = $state('map');
|
|
|
|
// Chart containers
|
|
let mapContainer: HTMLDivElement;
|
|
let distContainer: HTMLDivElement;
|
|
let corrContainer: HTMLDivElement;
|
|
|
|
// Plotly reference
|
|
let Plotly: any = $state(null);
|
|
let plotlyLoaded = $state(false);
|
|
|
|
// Load Plotly on mount
|
|
$effect(() => {
|
|
if (typeof window !== 'undefined' && !plotlyLoaded) {
|
|
const script = document.createElement('script');
|
|
script.src = 'https://cdn.plot.ly/plotly-2.27.0.min.js';
|
|
script.onload = () => {
|
|
Plotly = (window as any).Plotly;
|
|
plotlyLoaded = true;
|
|
loadData();
|
|
};
|
|
document.head.appendChild(script);
|
|
}
|
|
});
|
|
|
|
async function loadData() {
|
|
loading = true;
|
|
error = '';
|
|
try {
|
|
// Load metadata
|
|
const metaRes = await fetch(`${API_BASE}/house/metadata`);
|
|
metadata = await metaRes.json();
|
|
|
|
// Load initial data and charts
|
|
await updateCharts();
|
|
} catch (e) {
|
|
error = e instanceof Error ? e.message : 'Failed to load data';
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
async function updateCharts() {
|
|
if (!Plotly) return;
|
|
|
|
try {
|
|
// Fetch filtered house data
|
|
const params = new URLSearchParams();
|
|
if (minPrice > 0) params.append('min_price', minPrice.toString());
|
|
if (maxPrice < 8000000) params.append('max_price', maxPrice.toString());
|
|
if (minBedrooms > 0) params.append('min_bedrooms', minBedrooms.toString());
|
|
if (maxBedrooms < 10) params.append('max_bedrooms', maxBedrooms.toString());
|
|
if (waterfrontOnly) params.append('waterfront', 'true');
|
|
if (selectedZipcode) params.append('zipcode', selectedZipcode);
|
|
params.append('sample_size', '2000');
|
|
|
|
const dataRes = await fetch(`${API_BASE}/house/data?${params}`);
|
|
const result = await dataRes.json();
|
|
houseData = result.data;
|
|
|
|
// Fetch statistics
|
|
const statsRes = await fetch(`${API_BASE}/house/statistics`);
|
|
statistics = await statsRes.json();
|
|
|
|
// Update active chart (small delay to ensure DOM is ready)
|
|
setTimeout(() => {
|
|
if (activeTab === 'map') renderMap();
|
|
else if (activeTab === 'distribution') renderDistribution();
|
|
else if (activeTab === 'correlation') renderCorrelation();
|
|
}, 50);
|
|
} catch (e) {
|
|
error = e instanceof Error ? e.message : 'Failed to update charts';
|
|
}
|
|
}
|
|
|
|
function renderMap() {
|
|
if (!mapContainer || !Plotly || houseData.length === 0) return;
|
|
|
|
// Color scale based on price
|
|
const prices = houseData.map((d) => d.price);
|
|
const minP = Math.min(...prices);
|
|
const maxP = Math.max(...prices);
|
|
|
|
const trace = {
|
|
type: 'scattermapbox',
|
|
lat: houseData.map((d) => d.lat),
|
|
lon: houseData.map((d) => d.long),
|
|
mode: 'markers',
|
|
marker: {
|
|
size: houseData.map((d) => Math.max(5, Math.sqrt(d.sqft_living / 100))),
|
|
color: houseData.map((d) => d.price),
|
|
colorscale: 'Viridis',
|
|
cmin: minP,
|
|
cmax: maxP,
|
|
colorbar: {
|
|
title: 'Price ($)',
|
|
tickformat: ',.0f'
|
|
},
|
|
opacity: 0.7
|
|
},
|
|
text: houseData.map(
|
|
(d) =>
|
|
`Price: $${d.price.toLocaleString()}<br>` +
|
|
`Beds: ${d.bedrooms} | Baths: ${d.bathrooms}<br>` +
|
|
`Sqft: ${d.sqft_living.toLocaleString()}<br>` +
|
|
`Year Built: ${d.yr_built}<br>` +
|
|
`Grade: ${d.grade}`
|
|
),
|
|
hoverinfo: 'text'
|
|
};
|
|
|
|
const layout = {
|
|
mapbox: {
|
|
style: 'open-street-map',
|
|
center: {
|
|
lat: metadata?.location?.center?.[0] || 47.5,
|
|
lon: metadata?.location?.center?.[1] || -122.2
|
|
},
|
|
zoom: 9
|
|
},
|
|
margin: { l: 0, r: 0, t: 30, b: 0 },
|
|
title: {
|
|
text: `King County House Prices (${houseData.length} houses shown)`,
|
|
font: { size: 16 }
|
|
},
|
|
height: 500
|
|
};
|
|
|
|
Plotly.newPlot(mapContainer, [trace], layout, { responsive: true });
|
|
}
|
|
|
|
async function renderDistribution() {
|
|
if (!distContainer || !Plotly) return;
|
|
|
|
try {
|
|
const res = await fetch(`${API_BASE}/house/price-distribution?bins=30`);
|
|
const dist = await res.json();
|
|
|
|
const trace = {
|
|
x: dist.bin_centers,
|
|
y: dist.counts,
|
|
type: 'bar',
|
|
marker: {
|
|
color: dist.bin_centers,
|
|
colorscale: 'Viridis'
|
|
}
|
|
};
|
|
|
|
const layout = {
|
|
title: 'Price Distribution',
|
|
xaxis: {
|
|
title: 'Price ($)',
|
|
tickformat: ',.0f'
|
|
},
|
|
yaxis: { title: 'Count' },
|
|
margin: { l: 60, r: 30, t: 50, b: 60 },
|
|
height: 400
|
|
};
|
|
|
|
Plotly.newPlot(distContainer, [trace], layout, { responsive: true });
|
|
} catch (e) {
|
|
console.error('Failed to render distribution:', e);
|
|
}
|
|
}
|
|
|
|
async function renderCorrelation() {
|
|
if (!corrContainer || !Plotly) return;
|
|
|
|
try {
|
|
const res = await fetch(`${API_BASE}/house/correlation`);
|
|
const corr = await res.json();
|
|
|
|
const trace = {
|
|
z: corr.correlation,
|
|
x: corr.columns,
|
|
y: corr.columns,
|
|
type: 'heatmap',
|
|
colorscale: 'RdBu',
|
|
zmin: -1,
|
|
zmax: 1,
|
|
colorbar: { title: 'Correlation' }
|
|
};
|
|
|
|
const layout = {
|
|
title: 'Feature Correlation Matrix',
|
|
margin: { l: 100, r: 30, t: 50, b: 100 },
|
|
height: 500,
|
|
xaxis: { tickangle: 45 },
|
|
yaxis: { autorange: 'reversed' }
|
|
};
|
|
|
|
Plotly.newPlot(corrContainer, [trace], layout, { responsive: true });
|
|
} catch (e) {
|
|
console.error('Failed to render correlation:', e);
|
|
}
|
|
}
|
|
|
|
async function predictPrice() {
|
|
try {
|
|
const res = await fetch(`${API_BASE}/house/predict`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
bedrooms: predBedrooms,
|
|
bathrooms: predBathrooms,
|
|
sqft: predSqft,
|
|
age: predAge
|
|
})
|
|
});
|
|
prediction = await res.json();
|
|
} catch (e) {
|
|
error = 'Prediction failed';
|
|
}
|
|
}
|
|
|
|
function handleTabChange(tab: 'map' | 'distribution' | 'correlation') {
|
|
activeTab = tab;
|
|
setTimeout(() => {
|
|
if (tab === 'map') renderMap();
|
|
else if (tab === 'distribution') renderDistribution();
|
|
else if (tab === 'correlation') renderCorrelation();
|
|
}, 100);
|
|
}
|
|
|
|
function formatCurrency(value: number): string {
|
|
return new Intl.NumberFormat('en-US', {
|
|
style: 'currency',
|
|
currency: 'USD',
|
|
minimumFractionDigits: 0,
|
|
maximumFractionDigits: 0
|
|
}).format(value);
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>House Price Predictor | Build with AI</title>
|
|
<meta name="description" content="Seattle/King County house price prediction and visualization tool with ML-powered estimates." />
|
|
</svelte:head>
|
|
|
|
<div class="min-h-screen bg-gradient-to-b from-slate-50 to-white">
|
|
<Navbar />
|
|
|
|
<main class="pt-24 pb-16 px-6">
|
|
<div class="max-w-7xl mx-auto">
|
|
<!-- Header -->
|
|
<div class="mb-6">
|
|
<a href="/tools" class="text-primary-600 hover:text-primary-700 text-sm mb-2 inline-block">← Back to Tools</a>
|
|
<h1 class="text-2xl font-bold text-gray-900">House Price Predictor</h1>
|
|
<p class="text-gray-600 mt-1">
|
|
Seattle/King County house price prediction and visualization (21,613 houses, 2014-2015)
|
|
</p>
|
|
</div>
|
|
|
|
{#if loading}
|
|
<div class="flex items-center justify-center h-64">
|
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
|
</div>
|
|
{:else if error}
|
|
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-4">
|
|
{error}
|
|
</div>
|
|
{:else}
|
|
<div class="grid grid-cols-1 lg:grid-cols-6 gap-4">
|
|
<!-- Left Panel: Filters & Selection -->
|
|
<div class="lg:col-span-1 space-y-4">
|
|
<!-- Filters Card -->
|
|
<div class="bg-white rounded-xl shadow-sm p-3 border border-gray-100">
|
|
<h2 class="text-sm font-semibold mb-3 flex items-center gap-1">
|
|
<span>Filters</span>
|
|
</h2>
|
|
|
|
<div class="space-y-3">
|
|
<div>
|
|
<span class="block text-xs font-medium text-gray-700 mb-1">Price Range</span>
|
|
<div class="flex gap-2">
|
|
<input
|
|
type="number"
|
|
bind:value={minPrice}
|
|
class="w-1/2 px-2 py-1 text-sm border rounded"
|
|
placeholder="Min"
|
|
/>
|
|
<input
|
|
type="number"
|
|
bind:value={maxPrice}
|
|
class="w-1/2 px-2 py-1 text-sm border rounded"
|
|
placeholder="Max"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<span class="block text-xs font-medium text-gray-700 mb-1">Bedrooms</span>
|
|
<div class="flex gap-2">
|
|
<input
|
|
type="number"
|
|
bind:value={minBedrooms}
|
|
min="0"
|
|
max="10"
|
|
class="w-1/2 px-2 py-1 text-sm border rounded"
|
|
/>
|
|
<input
|
|
type="number"
|
|
bind:value={maxBedrooms}
|
|
min="0"
|
|
max="10"
|
|
class="w-1/2 px-2 py-1 text-sm border rounded"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<span class="block text-xs font-medium text-gray-700 mb-1">Zipcode</span>
|
|
<select bind:value={selectedZipcode} class="w-full px-2 py-1 text-sm border rounded">
|
|
<option value="">All Zipcodes</option>
|
|
{#if metadata?.zipcodes}
|
|
{#each metadata.zipcodes as zip}
|
|
<option value={zip}>{zip}</option>
|
|
{/each}
|
|
{/if}
|
|
</select>
|
|
</div>
|
|
|
|
<label class="flex items-center gap-2">
|
|
<input type="checkbox" bind:checked={waterfrontOnly} class="rounded" />
|
|
<span class="text-sm text-gray-700">Waterfront only</span>
|
|
</label>
|
|
|
|
<button
|
|
onclick={updateCharts}
|
|
class="w-full bg-primary-600 text-white py-2 rounded-lg hover:bg-primary-700 transition-colors"
|
|
>
|
|
Apply Filters
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Selection Card -->
|
|
<div class="bg-white rounded-xl shadow-sm p-3 border border-gray-100">
|
|
<h2 class="text-sm font-semibold mb-3 flex items-center gap-1">
|
|
<span>Selection</span>
|
|
</h2>
|
|
|
|
<div class="space-y-3">
|
|
<div>
|
|
<span class="block text-xs font-medium text-gray-700 mb-1">
|
|
Beds: {predBedrooms}
|
|
</span>
|
|
<input
|
|
type="range"
|
|
bind:value={predBedrooms}
|
|
min="1"
|
|
max="10"
|
|
class="w-full"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<span class="block text-xs font-medium text-gray-700 mb-1">
|
|
Baths: {predBathrooms}
|
|
</span>
|
|
<input
|
|
type="range"
|
|
bind:value={predBathrooms}
|
|
min="1"
|
|
max="6"
|
|
step="0.5"
|
|
class="w-full"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<span class="block text-xs font-medium text-gray-700 mb-1">
|
|
Sqft: {predSqft.toLocaleString()}
|
|
</span>
|
|
<input
|
|
type="range"
|
|
bind:value={predSqft}
|
|
min="500"
|
|
max="10000"
|
|
step="100"
|
|
class="w-full"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<span class="block text-xs font-medium text-gray-700 mb-1">
|
|
Age: {predAge}yr
|
|
</span>
|
|
<input
|
|
type="range"
|
|
bind:value={predAge}
|
|
min="0"
|
|
max="120"
|
|
class="w-full"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main Chart Area -->
|
|
<div class="lg:col-span-4">
|
|
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
|
<!-- Tabs -->
|
|
<div class="flex border-b border-gray-200">
|
|
<button
|
|
onclick={() => handleTabChange('map')}
|
|
class="flex-1 px-4 py-3 text-sm font-medium transition-colors
|
|
{activeTab === 'map'
|
|
? 'bg-primary-50 text-primary-700 border-b-2 border-primary-600'
|
|
: 'text-gray-600 hover:bg-gray-50'}"
|
|
>
|
|
Map View
|
|
</button>
|
|
<button
|
|
onclick={() => handleTabChange('distribution')}
|
|
class="flex-1 px-4 py-3 text-sm font-medium transition-colors
|
|
{activeTab === 'distribution'
|
|
? 'bg-primary-50 text-primary-700 border-b-2 border-primary-600'
|
|
: 'text-gray-600 hover:bg-gray-50'}"
|
|
>
|
|
Distribution
|
|
</button>
|
|
<button
|
|
onclick={() => handleTabChange('correlation')}
|
|
class="flex-1 px-4 py-3 text-sm font-medium transition-colors
|
|
{activeTab === 'correlation'
|
|
? 'bg-primary-50 text-primary-700 border-b-2 border-primary-600'
|
|
: 'text-gray-600 hover:bg-gray-50'}"
|
|
>
|
|
Correlations
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Chart Containers -->
|
|
<div class="p-4">
|
|
<div bind:this={mapContainer} class:hidden={activeTab !== 'map'}></div>
|
|
<div bind:this={distContainer} class:hidden={activeTab !== 'distribution'}></div>
|
|
<div bind:this={corrContainer} class:hidden={activeTab !== 'correlation'}></div>
|
|
</div>
|
|
|
|
<!-- Predict Price Bar -->
|
|
<div class="px-4 py-3 bg-gray-50 border-t border-gray-200 flex items-center justify-between gap-4">
|
|
<div class="flex items-center gap-4 text-sm text-gray-600">
|
|
<span>{predBedrooms} bed</span>
|
|
<span>{predBathrooms} bath</span>
|
|
<span>{predSqft.toLocaleString()} sqft</span>
|
|
<span>{predAge}yr old</span>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<button
|
|
onclick={predictPrice}
|
|
class="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium"
|
|
>
|
|
Predict Price
|
|
</button>
|
|
{#if prediction}
|
|
<div class="px-4 py-2 bg-green-100 rounded-lg border border-green-300">
|
|
<span class="text-xl font-bold text-green-700">{prediction.formatted_price}</span>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right Panel: Statistics -->
|
|
<div class="lg:col-span-1">
|
|
<div class="bg-white rounded-xl shadow-sm p-3 border border-gray-100">
|
|
<h2 class="text-sm font-semibold mb-3 flex items-center gap-1">
|
|
<span>Stats</span>
|
|
</h2>
|
|
|
|
{#if statistics}
|
|
<div class="space-y-2">
|
|
<div class="p-2 bg-gray-50 rounded-lg">
|
|
<p class="text-xs text-gray-500">Total</p>
|
|
<p class="text-base font-bold text-gray-900">
|
|
{statistics.count?.toLocaleString()}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="p-2 bg-blue-50 rounded-lg">
|
|
<p class="text-xs text-blue-600">Avg</p>
|
|
<p class="text-base font-bold text-blue-700">
|
|
{formatCurrency(statistics.mean)}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="p-2 bg-green-50 rounded-lg">
|
|
<p class="text-xs text-green-600">Median</p>
|
|
<p class="text-base font-bold text-green-700">
|
|
{formatCurrency(statistics.median)}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 gap-1">
|
|
<div class="p-1 bg-gray-50 rounded text-center">
|
|
<p class="text-xs text-gray-500">Min</p>
|
|
<p class="text-xs font-semibold">{formatCurrency(statistics.min)}</p>
|
|
</div>
|
|
<div class="p-1 bg-gray-50 rounded text-center">
|
|
<p class="text-xs text-gray-500">Max</p>
|
|
<p class="text-xs font-semibold">{formatCurrency(statistics.max)}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{#if statistics.percentiles}
|
|
<div class="mt-2 pt-2 border-t border-gray-100">
|
|
<p class="text-xs font-medium text-gray-600 mb-1">Percentiles</p>
|
|
<div class="space-y-0.5 text-xs">
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-500">25th</span>
|
|
<span class="font-medium">{formatCurrency(statistics.percentiles['25'])}</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-500">75th</span>
|
|
<span class="font-medium">{formatCurrency(statistics.percentiles['75'])}</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-500">90th</span>
|
|
<span class="font-medium">{formatCurrency(statistics.percentiles['90'])}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if metadata}
|
|
<div class="mt-3 pt-2 border-t border-gray-100">
|
|
<p class="text-xs text-gray-500">
|
|
{metadata.region}<br/>
|
|
{metadata.data_period}
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</main>
|
|
|
|
<Footer />
|
|
</div>
|