Format and lint
This commit is contained in:
parent
42ee2d4c51
commit
04a78e7bfe
75 changed files with 1290 additions and 719 deletions
|
|
@ -1,5 +1,10 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeFeature } from '../../types';
|
||||
import type {
|
||||
FeatureFilters,
|
||||
FeatureMeta,
|
||||
HexagonStatsResponse,
|
||||
PostcodeFeature,
|
||||
} from '../../types';
|
||||
import type { HexagonLocation } from '../../lib/external-search';
|
||||
import { formatValue, formatFilterValue, calculateHistogramMean } from '../../lib/format';
|
||||
import { groupFeaturesByCategory } from '../../lib/features';
|
||||
|
|
@ -33,7 +38,6 @@ interface AreaPaneProps {
|
|||
aiSummary?: string;
|
||||
aiSummaryLoading?: boolean;
|
||||
aiSummaryError?: string | null;
|
||||
onRetryAiSummary?: () => void;
|
||||
}
|
||||
|
||||
export default function AreaPane({
|
||||
|
|
@ -51,7 +55,6 @@ export default function AreaPane({
|
|||
aiSummary,
|
||||
aiSummaryLoading,
|
||||
aiSummaryError,
|
||||
onRetryAiSummary,
|
||||
}: AreaPaneProps) {
|
||||
// For postcodes, use local data for count
|
||||
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
|
||||
|
|
@ -145,7 +148,9 @@ export default function AreaPane({
|
|||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<LightbulbIcon className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400" />
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">AI Summary</span>
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
|
||||
AI Summary
|
||||
</span>
|
||||
</div>
|
||||
<ChevronIcon
|
||||
direction={aiSummaryExpanded ? 'down' : 'right'}
|
||||
|
|
@ -156,15 +161,7 @@ export default function AreaPane({
|
|||
<>
|
||||
{aiSummaryError ? (
|
||||
<div className="text-xs text-warm-600 dark:text-warm-400">
|
||||
<span>Failed to generate summary. </span>
|
||||
{onRetryAiSummary && (
|
||||
<button
|
||||
onClick={onRetryAiSummary}
|
||||
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
Failed to generate summary.
|
||||
</div>
|
||||
) : aiSummaryLoading ? (
|
||||
<div className="space-y-1.5">
|
||||
|
|
@ -191,19 +188,24 @@ export default function AreaPane({
|
|||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-teal-500 dark:bg-teal-400 rounded" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Teal bars</span> show the distribution in this selected area
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Teal bars</span>{' '}
|
||||
show the distribution in this selected area
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-warm-300/60 dark:bg-warm-600/60 rounded" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Gray bars</span> show the overall distribution across all areas
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Gray bars</span>{' '}
|
||||
show the overall distribution across all areas
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-px border-t border-dashed border-warm-500 dark:border-warm-400" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Dashed line</span> indicates the global average
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">
|
||||
Dashed line
|
||||
</span>{' '}
|
||||
indicates the global average
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -219,9 +221,9 @@ export default function AreaPane({
|
|||
|
||||
// Features that are part of a stacked enum config (rendered as compact charts)
|
||||
const stackedEnumFeatureNames = new Set(
|
||||
stackedEnumCharts?.flatMap((c) =>
|
||||
(stackedEnumCharts?.flatMap((c) =>
|
||||
[c.feature, ...c.components].filter(Boolean)
|
||||
) as string[] ?? []
|
||||
) as string[]) ?? []
|
||||
);
|
||||
|
||||
const isExpanded = !collapsedGroups.has(group.name);
|
||||
|
|
@ -234,40 +236,156 @@ export default function AreaPane({
|
|||
onToggle={() => toggleGroup(group.name)}
|
||||
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
|
||||
/>
|
||||
{isExpanded && <div className="px-3 py-2 space-y-3">
|
||||
{/* Price History in Property group */}
|
||||
{group.name === 'Property' && stats.price_history && (() => {
|
||||
// Only show chart if there are at least 2 unique years
|
||||
const uniqueYears = new Set(stats.price_history.map(p => Math.floor(p.year)));
|
||||
return uniqueYears.size > 1;
|
||||
})() && (
|
||||
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">Price History</span>
|
||||
<PriceHistoryChart points={stats.price_history} />
|
||||
</div>
|
||||
)}
|
||||
{stackedCharts
|
||||
? // Render stacked charts for this group
|
||||
stackedCharts.map((chart) => {
|
||||
const segments = chart.components
|
||||
.map((name) => ({
|
||||
name,
|
||||
value: numericByName.get(name)?.mean ?? 0,
|
||||
}))
|
||||
{isExpanded && (
|
||||
<div className="px-3 py-2 space-y-3">
|
||||
{/* Price History in Property group */}
|
||||
{group.name === 'Property' &&
|
||||
stats.price_history &&
|
||||
(() => {
|
||||
// Only show chart if there are at least 2 unique years
|
||||
const uniqueYears = new Set(
|
||||
stats.price_history.map((p) => Math.floor(p.year))
|
||||
);
|
||||
return uniqueYears.size > 1;
|
||||
})() && (
|
||||
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
Price History
|
||||
</span>
|
||||
<PriceHistoryChart points={stats.price_history} />
|
||||
</div>
|
||||
)}
|
||||
{stackedCharts
|
||||
? // Render stacked charts for this group
|
||||
stackedCharts.map((chart) => {
|
||||
const segments = chart.components
|
||||
.map((name) => ({
|
||||
name,
|
||||
value: numericByName.get(name)?.mean ?? 0,
|
||||
}))
|
||||
.filter((s) => s.value > 0);
|
||||
|
||||
// Use aggregate feature stats if available, otherwise sum components
|
||||
const aggregateStats = chart.feature
|
||||
? numericByName.get(chart.feature)
|
||||
: undefined;
|
||||
const total = aggregateStats
|
||||
? aggregateStats.mean
|
||||
: segments.reduce((sum, s) => sum + s.value, 0);
|
||||
|
||||
const featureMeta = chart.feature
|
||||
? globalFeatureByName.get(chart.feature)
|
||||
: undefined;
|
||||
|
||||
if (total === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={chart.label}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline mb-1.5">
|
||||
{featureMeta ? (
|
||||
<FeatureLabel
|
||||
feature={{ ...featureMeta, name: chart.label }}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
||||
{chart.label}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(total)}
|
||||
{chart.unit ? ` ${chart.unit}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<StackedBarChart segments={segments} total={total} />
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: // Default: render each feature individually (skip stacked enum features)
|
||||
group.features
|
||||
.filter((f) => !stackedEnumFeatureNames.has(f.name))
|
||||
.map((feature) => {
|
||||
const numericStats = numericByName.get(feature.name);
|
||||
const enumStats = enumByName.get(feature.name);
|
||||
|
||||
if (numericStats) {
|
||||
const globalFeature = globalFeatureByName.get(feature.name);
|
||||
const globalHistogram = globalFeature?.histogram;
|
||||
const globalMean = globalHistogram
|
||||
? calculateHistogramMean(globalHistogram)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline">
|
||||
<FeatureLabel
|
||||
feature={feature}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(numericStats.mean, feature)}
|
||||
</span>
|
||||
</div>
|
||||
{numericStats.histogram &&
|
||||
(globalHistogram ? (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={globalHistogram.counts}
|
||||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
globalMean={globalMean}
|
||||
formatLabel={formatFilterValue}
|
||||
/>
|
||||
) : (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={numericStats.histogram.counts}
|
||||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
formatLabel={formatFilterValue}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (enumStats) {
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
|
||||
<EnumBarChart counts={enumStats.counts} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
{/* Stacked enum charts */}
|
||||
{stackedEnumCharts?.map((chart) => {
|
||||
const featureMeta = chart.feature
|
||||
? globalFeatureByName.get(chart.feature)
|
||||
: undefined;
|
||||
|
||||
// Single component: render as a stacked bar (like crime charts)
|
||||
if (chart.components.length === 1) {
|
||||
const stats = enumByName.get(chart.components[0]);
|
||||
if (!stats) return null;
|
||||
|
||||
const segments = chart.valueOrder
|
||||
.map((value) => ({ name: value, value: stats.counts[value] ?? 0 }))
|
||||
.filter((s) => s.value > 0);
|
||||
|
||||
// Use aggregate feature stats if available, otherwise sum components
|
||||
const aggregateStats = chart.feature
|
||||
? numericByName.get(chart.feature)
|
||||
: undefined;
|
||||
const total = aggregateStats
|
||||
? aggregateStats.mean
|
||||
: segments.reduce((sum, s) => sum + s.value, 0);
|
||||
|
||||
const featureMeta = chart.feature
|
||||
? globalFeatureByName.get(chart.feature)
|
||||
: undefined;
|
||||
|
||||
const total = segments.reduce((sum, s) => sum + s.value, 0);
|
||||
if (total === 0) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -278,7 +396,7 @@ export default function AreaPane({
|
|||
<div className="flex justify-between items-baseline mb-1.5">
|
||||
{featureMeta ? (
|
||||
<FeatureLabel
|
||||
feature={{ ...featureMeta, name: chart.label }}
|
||||
feature={featureMeta}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
/>
|
||||
|
|
@ -288,166 +406,57 @@ export default function AreaPane({
|
|||
</span>
|
||||
)}
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(total)}
|
||||
{chart.unit ? ` ${chart.unit}` : ''}
|
||||
{total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<StackedBarChart segments={segments} total={total} />
|
||||
<StackedBarChart
|
||||
segments={segments}
|
||||
total={total}
|
||||
colorMap={Object.fromEntries(
|
||||
chart.valueOrder.map((v, i) => [v, chart.valueColors[i]])
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: // Default: render each feature individually (skip stacked enum features)
|
||||
group.features
|
||||
.filter((f) => !stackedEnumFeatureNames.has(f.name))
|
||||
.map((feature) => {
|
||||
const numericStats = numericByName.get(feature.name);
|
||||
const enumStats = enumByName.get(feature.name);
|
||||
}
|
||||
|
||||
if (numericStats) {
|
||||
const globalFeature = globalFeatureByName.get(feature.name);
|
||||
const globalHistogram = globalFeature?.histogram;
|
||||
const globalMean = globalHistogram
|
||||
? calculateHistogramMean(globalHistogram)
|
||||
: undefined;
|
||||
// Multi-component: render as compact multi-row chart (like risk features)
|
||||
const components = chart.components
|
||||
.map((name) => {
|
||||
const stats = enumByName.get(name);
|
||||
return stats ? { label: name, stats } : null;
|
||||
})
|
||||
.filter((c): c is NonNullable<typeof c> => c !== null);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline">
|
||||
<FeatureLabel
|
||||
feature={feature}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(numericStats.mean, feature)}
|
||||
</span>
|
||||
</div>
|
||||
{numericStats.histogram && (
|
||||
globalHistogram ? (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={globalHistogram.counts}
|
||||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
globalMean={globalMean}
|
||||
formatLabel={formatFilterValue}
|
||||
/>
|
||||
) : (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={numericStats.histogram.counts}
|
||||
p1={numericStats.histogram.p1}
|
||||
p99={numericStats.histogram.p99}
|
||||
formatLabel={formatFilterValue}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (enumStats) {
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
|
||||
<EnumBarChart counts={enumStats.counts} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
{/* Stacked enum charts */}
|
||||
{stackedEnumCharts?.map((chart) => {
|
||||
const featureMeta = chart.feature
|
||||
? globalFeatureByName.get(chart.feature)
|
||||
: undefined;
|
||||
|
||||
// Single component: render as a stacked bar (like crime charts)
|
||||
if (chart.components.length === 1) {
|
||||
const stats = enumByName.get(chart.components[0]);
|
||||
if (!stats) return null;
|
||||
|
||||
const segments = chart.valueOrder
|
||||
.map((value) => ({ name: value, value: stats.counts[value] ?? 0 }))
|
||||
.filter((s) => s.value > 0);
|
||||
const total = segments.reduce((sum, s) => sum + s.value, 0);
|
||||
if (total === 0) return null;
|
||||
if (components.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={chart.label}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline mb-1.5">
|
||||
<div className="mb-1.5">
|
||||
{featureMeta ? (
|
||||
<FeatureLabel
|
||||
feature={featureMeta}
|
||||
feature={{ ...featureMeta, name: chart.label }}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{chart.label}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{total.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<StackedBarChart
|
||||
segments={segments}
|
||||
total={total}
|
||||
colorMap={Object.fromEntries(
|
||||
chart.valueOrder.map((v, i) => [v, chart.valueColors[i]])
|
||||
)}
|
||||
<StackedEnumChart
|
||||
components={components}
|
||||
valueOrder={chart.valueOrder}
|
||||
valueColors={chart.valueColors}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Multi-component: render as compact multi-row chart (like risk features)
|
||||
const components = chart.components
|
||||
.map((name) => {
|
||||
const stats = enumByName.get(name);
|
||||
return stats ? { label: name, stats } : null;
|
||||
})
|
||||
.filter((c): c is NonNullable<typeof c> => c !== null);
|
||||
|
||||
if (components.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={chart.label}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="mb-1.5">
|
||||
{featureMeta ? (
|
||||
<FeatureLabel
|
||||
feature={{ ...featureMeta, name: chart.label }}
|
||||
onShowInfo={setInfoFeature}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{chart.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<StackedEnumChart
|
||||
components={components}
|
||||
valueOrder={chart.valueOrder}
|
||||
valueColors={chart.valueColors}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>}
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue