Update chars
This commit is contained in:
parent
609dd5278c
commit
46585a4b2b
8 changed files with 522 additions and 285 deletions
|
|
@ -3,10 +3,11 @@ import type { FeatureFilters, FeatureMeta, HexagonStatsResponse, PostcodeData }
|
|||
import type { HexagonLocation } from '../lib/external-search';
|
||||
import { formatValue, calculateHistogramMean } from '../lib/format';
|
||||
import { groupFeaturesByCategory } from '../lib/features';
|
||||
import { CRIME_BREAKDOWNS } from '../lib/consts';
|
||||
import { STACKED_GROUPS } from '../lib/consts';
|
||||
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
|
||||
import EnumBarChart from './EnumBarChart';
|
||||
import StackedBarChart from './StackedBarChart';
|
||||
import PriceHistoryChart from './PriceHistoryChart';
|
||||
import ExternalSearchLinks from './ExternalSearchLinks';
|
||||
import { InfoIcon, CloseIcon } from './ui/Icons';
|
||||
import { IconButton } from './ui/IconButton';
|
||||
|
|
@ -104,17 +105,19 @@ export default function AreaPane({
|
|||
<LoadingSkeleton />
|
||||
) : stats ? (
|
||||
<div className="p-3 space-y-4">
|
||||
{stats.price_history && stats.price_history.length > 0 && (
|
||||
<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>
|
||||
)}
|
||||
{featureGroups.map((group) => {
|
||||
const hasData = group.features.some(
|
||||
(feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
|
||||
);
|
||||
if (!hasData) return null;
|
||||
|
||||
// For Crime group, only show aggregate features with stacked breakdown
|
||||
const isCrimeGroup = group.name === 'Crime';
|
||||
const featuresToRender = isCrimeGroup
|
||||
? group.features.filter((f) => f.name in CRIME_BREAKDOWNS)
|
||||
: group.features;
|
||||
const stackedCharts = STACKED_GROUPS[group.name];
|
||||
|
||||
return (
|
||||
<div key={group.name}>
|
||||
|
|
@ -122,38 +125,43 @@ export default function AreaPane({
|
|||
{group.name}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{featuresToRender.map((feature) => {
|
||||
const numericStats = numericByName.get(feature.name);
|
||||
const enumStats = enumByName.get(feature.name);
|
||||
|
||||
if (numericStats) {
|
||||
// Check if this is a crime aggregate that should show breakdown
|
||||
const breakdown = CRIME_BREAKDOWNS[feature.name];
|
||||
if (breakdown) {
|
||||
// Build segments from component crime means
|
||||
const segments = breakdown
|
||||
.map((componentName) => {
|
||||
const componentStats = numericByName.get(componentName);
|
||||
return {
|
||||
name: componentName,
|
||||
value: componentStats?.mean ?? 0,
|
||||
};
|
||||
})
|
||||
{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={feature.name}
|
||||
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="flex items-center gap-1 min-w-0 mr-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate">
|
||||
{feature.name}
|
||||
{chart.label}
|
||||
</span>
|
||||
{feature.detail && (
|
||||
{featureMeta?.detail && (
|
||||
<button
|
||||
onClick={() => setInfoFeature(feature)}
|
||||
onClick={() => setInfoFeature(featureMeta)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
||||
title="Feature info"
|
||||
>
|
||||
|
|
@ -162,96 +170,105 @@ export default function AreaPane({
|
|||
)}
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(numericStats.mean)} avg/yr
|
||||
{formatValue(total)}
|
||||
{chart.unit ? ` ${chart.unit}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<StackedBarChart segments={segments} total={numericStats.mean} />
|
||||
<StackedBarChart segments={segments} total={total} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})
|
||||
: // Default: render each feature individually
|
||||
group.features.map((feature) => {
|
||||
const numericStats = numericByName.get(feature.name);
|
||||
const enumStats = enumByName.get(feature.name);
|
||||
|
||||
// Regular numeric feature with histogram
|
||||
const globalFeature = globalFeatureByName.get(feature.name);
|
||||
const globalHistogram = globalFeature?.histogram;
|
||||
const globalMean = globalHistogram
|
||||
? calculateHistogramMean(globalHistogram)
|
||||
: undefined;
|
||||
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">
|
||||
<div className="flex items-center gap-1 min-w-0 mr-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate">
|
||||
{feature.name}
|
||||
</span>
|
||||
{feature.detail && (
|
||||
<button
|
||||
onClick={() => setInfoFeature(feature)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
||||
title="Feature info"
|
||||
>
|
||||
<InfoIcon className="w-3 h-3" />
|
||||
</button>
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline">
|
||||
<div className="flex items-center gap-1 min-w-0 mr-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate">
|
||||
{feature.name}
|
||||
</span>
|
||||
{feature.detail && (
|
||||
<button
|
||||
onClick={() => setInfoFeature(feature)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
||||
title="Feature info"
|
||||
>
|
||||
<InfoIcon className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
{formatValue(numericStats.mean)}
|
||||
</span>
|
||||
</div>
|
||||
{numericStats.histogram && (
|
||||
<>
|
||||
<div className="flex justify-between text-[10px] text-warm-400 dark:text-warm-500 mt-0.5">
|
||||
<span>{formatValue(numericStats.histogram.min)}</span>
|
||||
<span>{formatValue(numericStats.histogram.max)}</span>
|
||||
</div>
|
||||
{globalHistogram ? (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={globalHistogram.counts}
|
||||
min={numericStats.histogram.min}
|
||||
max={numericStats.histogram.max}
|
||||
globalMean={globalMean}
|
||||
/>
|
||||
) : (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={numericStats.histogram.counts}
|
||||
min={numericStats.histogram.min}
|
||||
max={numericStats.histogram.max}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<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.histogram.min)}</span>
|
||||
<span>{formatValue(numericStats.histogram.max)}</span>
|
||||
</div>
|
||||
{globalHistogram ? (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={globalHistogram.counts}
|
||||
min={numericStats.histogram.min}
|
||||
max={numericStats.histogram.max}
|
||||
globalMean={globalMean}
|
||||
/>
|
||||
) : (
|
||||
<DualHistogram
|
||||
localCounts={numericStats.histogram.counts}
|
||||
globalCounts={numericStats.histogram.counts}
|
||||
min={numericStats.histogram.min}
|
||||
max={numericStats.histogram.max}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (enumStats) {
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{feature.name}
|
||||
</span>
|
||||
{feature.detail && (
|
||||
<button
|
||||
onClick={() => setInfoFeature(feature)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
title="Feature info"
|
||||
>
|
||||
<InfoIcon className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<EnumBarChart counts={enumStats.counts} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (enumStats) {
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{feature.name}
|
||||
</span>
|
||||
{feature.detail && (
|
||||
<button
|
||||
onClick={() => setInfoFeature(feature)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
title="Feature info"
|
||||
>
|
||||
<InfoIcon className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<EnumBarChart counts={enumStats.counts} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ export default memo(function Filters({
|
|||
const enabledFeatureList = features.filter((f) => enabledFeatures.has(f.name));
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const [splitFraction, setSplitFraction] = useState(0.65);
|
||||
const draggingRef = useRef(false);
|
||||
const [showPhilosophy, setShowPhilosophy] = useState(false);
|
||||
|
|
@ -169,8 +170,9 @@ export default memo(function Filters({
|
|||
const handleSeparatorPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!draggingRef.current || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const headerHeight = headerRef.current?.offsetHeight ?? 0;
|
||||
const y = e.clientY - rect.top;
|
||||
const fraction = Math.min(0.8, Math.max(0.15, y / rect.height));
|
||||
const fraction = Math.min(0.8, Math.max(0.15, (y - headerHeight) / rect.height));
|
||||
setSplitFraction(fraction);
|
||||
}, []);
|
||||
|
||||
|
|
@ -183,7 +185,7 @@ export default memo(function Filters({
|
|||
ref={containerRef}
|
||||
className="flex flex-col bg-white dark:bg-navy-950 overflow-hidden h-full"
|
||||
>
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<div ref={headerRef} className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<button
|
||||
onClick={() => setShowPhilosophy(true)}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md flex items-center justify-center gap-2"
|
||||
|
|
|
|||
|
|
@ -171,14 +171,8 @@ export default memo(function Map({
|
|||
themeRef.current = theme;
|
||||
|
||||
const handleMapLoad = useCallback(
|
||||
(evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
|
||||
const map = evt.target;
|
||||
// Hide buildings to reduce visual clutter over hexagons
|
||||
try {
|
||||
map.setLayoutProperty('buildings', 'visibility', 'none');
|
||||
} catch {
|
||||
// layer may not exist
|
||||
}
|
||||
(_evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
|
||||
// Hexagons render below roads/buildings/labels so map features show on top
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
|
@ -337,15 +331,15 @@ export default memo(function Map({
|
|||
}
|
||||
}
|
||||
const range = clr[1] - clr[0];
|
||||
if (range === 0) return [...GRADIENT[0].color, 200] as [number, number, number, number];
|
||||
if (range === 0) return [...GRADIENT[0].color, 255] as [number, number, number, number];
|
||||
const t = ((val as number) - clr[0]) / range;
|
||||
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
|
||||
return [...rgb, 200] as [number, number, number, number];
|
||||
return [...rgb, 255] as [number, number, number, number];
|
||||
}
|
||||
const cr = countRangeRef.current;
|
||||
const c = d.count as number;
|
||||
const t = (c - cr.min) / (cr.max - cr.min);
|
||||
return [...countToColor(Math.max(0, Math.min(1, t))), 200] as [
|
||||
return [...countToColor(Math.max(0, Math.min(1, t))), 255] as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
|
|
@ -377,7 +371,7 @@ export default memo(function Map({
|
|||
onClick: handleHexagonClick,
|
||||
onHover: handleHexagonHover,
|
||||
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
||||
beforeId: 'water_waterway_label',
|
||||
beforeId: 'landuse_park',
|
||||
}),
|
||||
[data, colorTrigger, handleHexagonClick, handleHexagonHover]
|
||||
);
|
||||
|
|
@ -404,15 +398,15 @@ export default memo(function Map({
|
|||
}
|
||||
}
|
||||
const range = clr[1] - clr[0];
|
||||
if (range === 0) return [...GRADIENT[0].color, 200] as [number, number, number, number];
|
||||
if (range === 0) return [...GRADIENT[0].color, 255] as [number, number, number, number];
|
||||
const t = ((val as number) - clr[0]) / range;
|
||||
const rgb = normalizedToColor(Math.max(0, Math.min(1, t)));
|
||||
return [...rgb, 200] as [number, number, number, number];
|
||||
return [...rgb, 255] as [number, number, number, number];
|
||||
}
|
||||
const cr = postcodeCountRangeRef.current;
|
||||
const c = d.count as number;
|
||||
const t = (c - cr.min) / (cr.max - cr.min);
|
||||
return [...countToColor(Math.max(0, Math.min(1, t))), 200] as [
|
||||
return [...countToColor(Math.max(0, Math.min(1, t))), 255] as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
|
|
@ -442,7 +436,7 @@ export default memo(function Map({
|
|||
onClick: handlePostcodeClick,
|
||||
onHover: handlePostcodeHoverCallback,
|
||||
// @ts-expect-error beforeId is a MapboxOverlay interleave prop, not typed in LayerProps
|
||||
beforeId: 'water_waterway_label',
|
||||
beforeId: 'landuse_park',
|
||||
}),
|
||||
[postcodeData, postcodeColorTrigger, handlePostcodeClick, handlePostcodeHoverCallback]
|
||||
);
|
||||
|
|
|
|||
169
frontend/src/components/PriceHistoryChart.tsx
Normal file
169
frontend/src/components/PriceHistoryChart.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { useMemo } from 'react';
|
||||
import type { PricePoint } from '../types';
|
||||
import { formatValue } from '../lib/format';
|
||||
|
||||
interface PriceHistoryChartProps {
|
||||
points: PricePoint[];
|
||||
}
|
||||
|
||||
const PADDING = { top: 8, right: 8, bottom: 20, left: 42 };
|
||||
const HEIGHT = 120;
|
||||
|
||||
export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
||||
const { yearMin, yearMax, priceMin, priceMax, averages, priceTicks } = useMemo(() => {
|
||||
let yMin = Infinity,
|
||||
yMax = -Infinity,
|
||||
pMin = Infinity,
|
||||
pMax = -Infinity;
|
||||
for (const p of points) {
|
||||
if (p.year < yMin) yMin = p.year;
|
||||
if (p.year > yMax) yMax = p.year;
|
||||
if (p.price < pMin) pMin = p.price;
|
||||
if (p.price > pMax) pMax = p.price;
|
||||
}
|
||||
// Add 5% padding to price range
|
||||
const pRange = pMax - pMin || 1;
|
||||
pMin = Math.max(0, pMin - pRange * 0.05);
|
||||
pMax = pMax + pRange * 0.05;
|
||||
|
||||
// Yearly averages
|
||||
const byYear = new Map<number, { sum: number; count: number }>();
|
||||
for (const p of points) {
|
||||
const yr = Math.floor(p.year);
|
||||
const entry = byYear.get(yr);
|
||||
if (entry) {
|
||||
entry.sum += p.price;
|
||||
entry.count += 1;
|
||||
} else {
|
||||
byYear.set(yr, { sum: p.price, count: 1 });
|
||||
}
|
||||
}
|
||||
const avgs = Array.from(byYear.entries())
|
||||
.map(([yr, { sum, count }]) => ({ year: yr + 0.5, price: sum / count }))
|
||||
.sort((a, b) => a.year - b.year);
|
||||
|
||||
// Price ticks (3-5 nice round numbers)
|
||||
const ticks = niceTicksForRange(pMin, pMax, 4);
|
||||
|
||||
return { yearMin: yMin, yearMax: yMax, priceMin: pMin, priceMax: pMax, averages: avgs, priceTicks: ticks };
|
||||
}, [points]);
|
||||
|
||||
const scaleY = (price: number) => {
|
||||
const ratio = (price - priceMin) / (priceMax - priceMin || 1);
|
||||
return PADDING.top + (1 - ratio) * (HEIGHT - PADDING.top - PADDING.bottom);
|
||||
};
|
||||
|
||||
const yearRange = yearMax - yearMin || 1;
|
||||
|
||||
// Year labels: every 5 years
|
||||
const yearStart = Math.ceil(yearMin / 5) * 5;
|
||||
const yearLabels: number[] = [];
|
||||
for (let y = yearStart; y <= yearMax; y += 5) yearLabels.push(y);
|
||||
|
||||
const VB_W = 1000;
|
||||
|
||||
const scaleX = (year: number) => {
|
||||
const ratio = (year - yearMin) / yearRange;
|
||||
return PADDING.left + ratio * (VB_W - PADDING.left - PADDING.right);
|
||||
};
|
||||
|
||||
const avgPolyline = averages.map((a) => `${scaleX(a.year)},${scaleY(a.price)}`).join(' ');
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${VB_W} ${HEIGHT}`}
|
||||
preserveAspectRatio="none"
|
||||
className="w-full"
|
||||
style={{ height: HEIGHT }}
|
||||
>
|
||||
{/* Grid lines */}
|
||||
{priceTicks.map((tick) => (
|
||||
<line
|
||||
key={tick}
|
||||
x1={PADDING.left}
|
||||
y1={scaleY(tick)}
|
||||
x2={VB_W - PADDING.right}
|
||||
y2={scaleY(tick)}
|
||||
className="stroke-warm-200 dark:stroke-warm-700"
|
||||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Dots */}
|
||||
{points.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={scaleX(p.year)}
|
||||
cy={scaleY(p.price)}
|
||||
r={4}
|
||||
className="fill-teal-500 dark:fill-teal-400"
|
||||
opacity={0.35}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Average line */}
|
||||
{averages.length > 1 && (
|
||||
<polyline
|
||||
points={avgPolyline}
|
||||
fill="none"
|
||||
className="stroke-teal-600 dark:stroke-teal-400"
|
||||
strokeWidth={3}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Y-axis labels */}
|
||||
{priceTicks.map((tick) => (
|
||||
<text
|
||||
key={`label-${tick}`}
|
||||
x={PADDING.left - 4}
|
||||
y={scaleY(tick)}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
className="fill-warm-500 dark:fill-warm-400"
|
||||
style={{ fontSize: 28 }}
|
||||
>
|
||||
{formatValue(tick)}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* X-axis year labels */}
|
||||
{yearLabels.map((yr) => (
|
||||
<text
|
||||
key={yr}
|
||||
x={scaleX(yr)}
|
||||
y={HEIGHT - 2}
|
||||
textAnchor="middle"
|
||||
className="fill-warm-500 dark:fill-warm-400"
|
||||
style={{ fontSize: 28 }}
|
||||
>
|
||||
{yr}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Generate ~count nice round tick values spanning [min, max]. */
|
||||
function niceTicksForRange(min: number, max: number, count: number): number[] {
|
||||
const range = max - min;
|
||||
if (range <= 0) return [min];
|
||||
const rough = range / count;
|
||||
// Round to a nice step: 1, 2, 5, 10, 20, 50, 100k, 200k, 500k, etc.
|
||||
const magnitude = Math.pow(10, Math.floor(Math.log10(rough)));
|
||||
let step: number;
|
||||
const normalized = rough / magnitude;
|
||||
if (normalized <= 1.5) step = magnitude;
|
||||
else if (normalized <= 3.5) step = 2 * magnitude;
|
||||
else if (normalized <= 7.5) step = 5 * magnitude;
|
||||
else step = 10 * magnitude;
|
||||
|
||||
const ticks: number[] = [];
|
||||
const start = Math.ceil(min / step) * step;
|
||||
for (let t = start; t <= max; t += step) {
|
||||
ticks.push(t);
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useMemo } from 'react';
|
||||
import { CRIME_SEGMENT_COLORS } from '../lib/consts';
|
||||
import { SEGMENT_COLORS } from '../lib/consts';
|
||||
import { formatValue } from '../lib/format';
|
||||
|
||||
interface Segment {
|
||||
|
|
@ -12,10 +12,11 @@ interface StackedBarChartProps {
|
|||
total: number;
|
||||
}
|
||||
|
||||
/** Shorten crime category names for the legend */
|
||||
function shortenCrimeName(name: string): string {
|
||||
/** Strip common suffixes/prefixes to produce short legend labels */
|
||||
function shortenLabel(name: string): string {
|
||||
return name
|
||||
.replace(' (avg/yr)', '')
|
||||
.replace(/^% /, '')
|
||||
.replace('and sexual offences', '')
|
||||
.replace('and arson', '')
|
||||
.replace('from the person', '')
|
||||
|
|
@ -43,33 +44,33 @@ export default function StackedBarChart({ segments, total }: StackedBarChartProp
|
|||
<div className="flex h-4 rounded overflow-hidden bg-warm-200 dark:bg-warm-700">
|
||||
{sortedSegments.map((segment, i) => {
|
||||
const pct = (segment.value / total) * 100;
|
||||
if (pct < 0.5) return null; // Skip tiny segments
|
||||
if (pct < 0.5) return null;
|
||||
return (
|
||||
<div
|
||||
key={segment.name}
|
||||
className="h-full transition-all"
|
||||
className="h-full"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
backgroundColor: CRIME_SEGMENT_COLORS[i % CRIME_SEGMENT_COLORS.length],
|
||||
backgroundColor: SEGMENT_COLORS[i % SEGMENT_COLORS.length],
|
||||
}}
|
||||
title={`${shortenCrimeName(segment.name)}: ${formatValue(segment.value)} (${pct.toFixed(1)}%)`}
|
||||
title={`${shortenLabel(segment.name)}: ${formatValue(segment.value)} (${pct.toFixed(1)}%)`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend - compact grid */}
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
|
||||
{sortedSegments.map((segment, i) => (
|
||||
<div key={segment.name} className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-2 h-2 rounded-sm shrink-0"
|
||||
style={{
|
||||
backgroundColor: CRIME_SEGMENT_COLORS[i % CRIME_SEGMENT_COLORS.length],
|
||||
backgroundColor: SEGMENT_COLORS[i % SEGMENT_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px] text-warm-600 dark:text-warm-400">
|
||||
{shortenCrimeName(segment.name)}
|
||||
{shortenLabel(segment.name)}
|
||||
</span>
|
||||
<span className="text-[10px] text-warm-400 dark:text-warm-500">
|
||||
{formatValue(segment.value)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue