build-valuecurve/apps/homepage/src/routes/tools/house-predictor/+page.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">&larr; 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>