Checkpoint all changes
This commit is contained in:
parent
65877acf95
commit
66c2a25457
28 changed files with 3035 additions and 621 deletions
243
frontend/src/components/AreaPane.tsx
Normal file
243
frontend/src/components/AreaPane.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import { useMemo } from 'react';
|
||||
import type { FeatureMeta, HexagonStatsResponse } from '../types';
|
||||
|
||||
interface AreaPaneProps {
|
||||
stats: HexagonStatsResponse | null;
|
||||
globalFeatures: FeatureMeta[];
|
||||
loading: boolean;
|
||||
hexagonId: string | null;
|
||||
isHoveredPreview: boolean;
|
||||
hoverMode: boolean;
|
||||
onHoverModeChange: (enabled: boolean) => void;
|
||||
onViewProperties: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatValue(value: number): string {
|
||||
if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(0)}k`;
|
||||
if (Number.isInteger(value)) return value.toLocaleString();
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
// Group features by their group field from globalFeatures
|
||||
function groupFeatures(
|
||||
globalFeatures: FeatureMeta[]
|
||||
): { name: string; features: FeatureMeta[] }[] {
|
||||
const groups: { name: string; features: FeatureMeta[] }[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const feature of globalFeatures) {
|
||||
const groupName = feature.group || 'Other';
|
||||
if (!seen.has(groupName)) {
|
||||
seen.add(groupName);
|
||||
groups.push({ name: groupName, features: [] });
|
||||
}
|
||||
groups.find((group) => group.name === groupName)!.features.push(feature);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function MiniHistogram({ counts, maxCount }: { counts: number[]; maxCount: number }) {
|
||||
if (maxCount === 0) return null;
|
||||
// Downsample to ~20 bars for display
|
||||
const targetBars = 20;
|
||||
const step = Math.max(1, Math.floor(counts.length / targetBars));
|
||||
const bars: number[] = [];
|
||||
for (let index = 0; index < counts.length; index += step) {
|
||||
let sum = 0;
|
||||
for (let offset = 0; offset < step && index + offset < counts.length; offset++) {
|
||||
sum += counts[index + offset];
|
||||
}
|
||||
bars.push(sum);
|
||||
}
|
||||
const barMax = Math.max(...bars, 1);
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-px h-8 mt-1">
|
||||
{bars.map((count, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex-1 bg-teal-500 dark:bg-teal-400 rounded-t-sm min-w-[2px]"
|
||||
style={{ height: `${(count / barMax) * 100}%`, opacity: count > 0 ? 1 : 0.1 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EnumBarChart({ counts }: { counts: Record<string, number> }) {
|
||||
const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA);
|
||||
const maxCount = Math.max(...entries.map(([, count]) => count), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-1 mt-1">
|
||||
{entries.map(([label, count]) => (
|
||||
<div key={label} className="flex items-center gap-2 text-xs">
|
||||
<span className="w-16 truncate text-warm-500 dark:text-warm-400 text-right shrink-0">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex-1 h-3 bg-warm-100 dark:bg-navy-700 rounded overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-teal-500 dark:bg-teal-400 rounded"
|
||||
style={{ width: `${(count / maxCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-warm-500 dark:text-warm-400 text-right shrink-0">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AreaPane({
|
||||
stats,
|
||||
globalFeatures,
|
||||
loading,
|
||||
hexagonId,
|
||||
isHoveredPreview,
|
||||
hoverMode,
|
||||
onHoverModeChange,
|
||||
onViewProperties,
|
||||
onClose,
|
||||
}: AreaPaneProps) {
|
||||
const featureGroups = useMemo(() => groupFeatures(globalFeatures), [globalFeatures]);
|
||||
|
||||
// Build lookup maps from stats
|
||||
const numericByName = useMemo(() => {
|
||||
if (!stats) return new Map();
|
||||
return new Map(stats.numeric_features.map((feature) => [feature.name, feature]));
|
||||
}, [stats]);
|
||||
|
||||
const enumByName = useMemo(() => {
|
||||
if (!stats) return new Map();
|
||||
return new Map(stats.enum_features.map((feature) => [feature.name, feature]));
|
||||
}, [stats]);
|
||||
|
||||
if (!hexagonId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-warm-500 dark:text-warm-400 px-4 text-center text-sm">
|
||||
Click a hexagon to view area statistics
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold dark:text-warm-100">Area Statistics</h2>
|
||||
{isHoveredPreview && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
|
||||
Preview
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onHoverModeChange(!hoverMode)}
|
||||
className={`p-1 rounded ${
|
||||
hoverMode
|
||||
? 'text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||
: 'text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
||||
}`}
|
||||
title={hoverMode ? 'Live preview on (click to lock)' : 'Live preview off (click to enable)'}
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 p-1"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{stats && (
|
||||
<p className="text-sm text-warm-600 dark:text-warm-400 mt-1">
|
||||
{stats.count.toLocaleString()} properties
|
||||
</p>
|
||||
)}
|
||||
{stats && (
|
||||
<button
|
||||
onClick={onViewProperties}
|
||||
className="mt-2 w-full text-sm py-1.5 rounded bg-teal-600 hover:bg-teal-700 text-white font-medium"
|
||||
>
|
||||
View {stats.count.toLocaleString()} Properties
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && !stats ? (
|
||||
<div className="p-4 text-warm-500 dark:text-warm-400 text-sm">Loading...</div>
|
||||
) : stats ? (
|
||||
<div className="p-3 space-y-4">
|
||||
{featureGroups.map((group) => {
|
||||
// Check if any feature in this group has data
|
||||
const hasData = group.features.some(
|
||||
(feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
|
||||
);
|
||||
if (!hasData) return null;
|
||||
|
||||
return (
|
||||
<div key={group.name}>
|
||||
<h3 className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wider mb-2">
|
||||
{group.name}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{group.features.map((feature) => {
|
||||
const numericStats = numericByName.get(feature.name);
|
||||
const enumStats = enumByName.get(feature.name);
|
||||
|
||||
if (numericStats) {
|
||||
const maxCount = Math.max(...numericStats.histogram.counts);
|
||||
return (
|
||||
<div key={feature.name} className="bg-warm-50 dark:bg-navy-800 rounded p-2">
|
||||
<div className="flex justify-between items-baseline">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
||||
{feature.name}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(numericStats.mean)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-warm-400 dark:text-warm-500 mt-0.5">
|
||||
<span>{formatValue(numericStats.min)}</span>
|
||||
<span>{formatValue(numericStats.max)}</span>
|
||||
</div>
|
||||
<MiniHistogram counts={numericStats.histogram.counts} maxCount={maxCount} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (enumStats) {
|
||||
return (
|
||||
<div key={feature.name} className="bg-warm-50 dark:bg-navy-800 rounded p-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{feature.name}
|
||||
</span>
|
||||
<EnumBarChart counts={enumStats.counts} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue