850 lines
37 KiB
TypeScript
850 lines
37 KiB
TypeScript
import { useMemo, useState, type MutableRefObject, type ReactNode } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { ts } from '../../i18n/server';
|
|
import type {
|
|
FeatureFilters,
|
|
FeatureGroup,
|
|
FeatureMeta,
|
|
FilterExclusion,
|
|
HexagonStatsResponse,
|
|
} from '../../types';
|
|
import { travelFieldKey, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
|
import type { HexagonLocation } from '../../lib/external-search';
|
|
import { formatStationDistance, type NearbyStation } from '../../lib/nearby-stations';
|
|
import {
|
|
formatValue,
|
|
formatFilterValue,
|
|
calculateHistogramMean,
|
|
roundedPercentages,
|
|
} from '../../lib/format';
|
|
import { groupFeaturesByCategory } from '../../lib/features';
|
|
import { getPoiCategoryLogoUrl } from '../../lib/map-utils';
|
|
import {
|
|
getActiveAmenityFilterFeatureNames,
|
|
isPoiFilterFeatureName,
|
|
} from '../../lib/poi-distance-filter';
|
|
import {
|
|
PARTY_FEATURE_COLORS,
|
|
STACKED_GROUPS,
|
|
STACKED_ENUM_GROUPS,
|
|
STACKED_SEGMENT_COLORS,
|
|
} from '../../lib/consts';
|
|
import { useNearbyStations } from '../../hooks/useNearbyStations';
|
|
import { useRetainedScrollTop } from '../../hooks/useRetainedScrollTop';
|
|
import { DualHistogram, LoadingSkeleton } from './DualHistogram';
|
|
import EnumBarChart from './EnumBarChart';
|
|
import StackedBarChart from './StackedBarChart';
|
|
import StackedEnumChart from './StackedEnumChart';
|
|
import PriceHistoryChart from './PriceHistoryChart';
|
|
import CrimeYearChart from './CrimeYearChart';
|
|
import ExternalSearchLinks from './ExternalSearchLinks';
|
|
import { InfoIcon, TransitIcon } from '../ui/icons';
|
|
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
|
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
|
import { EmptyState } from '../ui/EmptyState';
|
|
import { FeatureLabel } from '../ui/FeatureLabel';
|
|
import { IndeterminateProgressBar } from '../ui/IndeterminateProgressBar';
|
|
import StreetViewEmbed from './StreetViewEmbed';
|
|
import JourneyInstructions from './JourneyInstructions';
|
|
|
|
interface AreaPaneProps {
|
|
stats: HexagonStatsResponse | null;
|
|
globalFeatures: FeatureMeta[];
|
|
loading: boolean;
|
|
hexagonId: string | null;
|
|
isPostcode?: boolean;
|
|
hexagonLocation: HexagonLocation | null;
|
|
filters: FeatureFilters;
|
|
unfilteredCount?: number | null;
|
|
statsUseFilters: boolean;
|
|
onStatsUseFiltersChange: (useFilters: boolean) => void;
|
|
onNavigateToSource?: (slug: string, featureName: string) => void;
|
|
travelTimeEntries?: TravelTimeEntry[];
|
|
shareCode?: string;
|
|
isGroupExpanded: (name: string) => boolean;
|
|
onToggleGroup: (name: string) => void;
|
|
scrollTopRef?: MutableRefObject<number>;
|
|
scrollRestoreKey?: string | null;
|
|
scrollSaveDisabled?: boolean;
|
|
}
|
|
|
|
function normalizePercentageSegments<T extends { value: number }>(segments: T[]): T[] {
|
|
const total = segments.reduce((sum, segment) => sum + segment.value, 0);
|
|
const normalizedValues = roundedPercentages(
|
|
segments.map((segment) => segment.value),
|
|
total,
|
|
1
|
|
);
|
|
return segments.map((segment, index) => ({ ...segment, value: normalizedValues[index] }));
|
|
}
|
|
|
|
function filterValueFormat(feature?: FeatureMeta) {
|
|
if (!feature) return undefined;
|
|
return {
|
|
prefix: feature.prefix,
|
|
suffix: feature.suffix,
|
|
raw: feature.raw,
|
|
};
|
|
}
|
|
|
|
const STATION_GROUP_NAME = 'Transport';
|
|
const STATION_GROUP_NAMES = new Set([STATION_GROUP_NAME, 'Public Transport']);
|
|
|
|
function MetricTextLabel({ children }: { children: ReactNode }) {
|
|
return (
|
|
<span className="block min-w-0 flex-1 break-words text-[13px] font-medium leading-snug text-warm-900 dark:text-warm-100">
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function MetricFeatureLabel({
|
|
feature,
|
|
onShowInfo,
|
|
label,
|
|
aboutLabel,
|
|
}: {
|
|
feature: FeatureMeta;
|
|
onShowInfo: (feature: FeatureMeta) => void;
|
|
label?: string;
|
|
aboutLabel: string;
|
|
}) {
|
|
return (
|
|
<div className="flex min-w-0 items-start gap-1.5">
|
|
<MetricTextLabel>{label ?? ts(feature.name)}</MetricTextLabel>
|
|
{feature.detail && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onShowInfo(feature)}
|
|
className="-m-1 shrink-0 rounded p-1 text-warm-400 hover:bg-warm-100 hover:text-warm-700 dark:hover:bg-navy-800 dark:hover:text-warm-200"
|
|
title={aboutLabel}
|
|
aria-label={aboutLabel}
|
|
>
|
|
<InfoIcon className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MetricRow({
|
|
label,
|
|
chart,
|
|
value,
|
|
valueTitle,
|
|
className = '',
|
|
}: {
|
|
label: ReactNode;
|
|
chart?: ReactNode;
|
|
value?: ReactNode;
|
|
valueTitle?: string;
|
|
className?: string;
|
|
}) {
|
|
return (
|
|
<div
|
|
className={`grid min-h-10 grid-cols-[minmax(0,1fr)_6.5rem_minmax(3.5rem,auto)] items-center gap-3 py-1.5 ${className}`}
|
|
>
|
|
<div className="min-w-0">{label}</div>
|
|
<div className="w-[6.5rem] justify-self-end">{chart}</div>
|
|
<div
|
|
className="min-w-[3.5rem] max-w-[7rem] truncate text-right text-sm font-semibold leading-tight tabular-nums text-navy-950 dark:text-warm-50"
|
|
title={valueTitle}
|
|
>
|
|
{value}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NearbyStationsCard({ location }: { location: HexagonLocation }) {
|
|
const { t } = useTranslation();
|
|
const origin = useMemo(
|
|
() => ({ lat: location.lat, lon: location.lon }),
|
|
[location.lat, location.lon]
|
|
);
|
|
const { stations, loading } = useNearbyStations(origin);
|
|
|
|
return (
|
|
<div className="py-1.5">
|
|
<div className="flex items-center gap-2 py-1">
|
|
<TransitIcon className="h-4 w-4 text-teal-600 dark:text-teal-400" />
|
|
<MetricTextLabel>{t('areaPane.closestStations')}</MetricTextLabel>
|
|
{loading && (
|
|
<span className="ml-auto h-3 w-3 rounded-full border-2 border-teal-600 border-t-transparent dark:border-teal-400 dark:border-t-transparent animate-spin" />
|
|
)}
|
|
</div>
|
|
{stations.length > 0 ? (
|
|
<ol className="divide-y divide-warm-100 dark:divide-navy-800">
|
|
{stations.map((station) => (
|
|
<NearbyStationRow key={station.id} station={station} />
|
|
))}
|
|
</ol>
|
|
) : (
|
|
<div className="py-2 text-sm text-warm-500 dark:text-warm-400">
|
|
{loading ? t('common.loading') : t('areaPane.noNearbyStations')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NearbyStationRow({ station }: { station: NearbyStation }) {
|
|
const icon = getPoiCategoryLogoUrl(station.category, station.icon_category);
|
|
|
|
return (
|
|
<li className="flex items-center gap-2 px-3 py-2">
|
|
{icon ? (
|
|
<img
|
|
src={icon}
|
|
alt=""
|
|
aria-hidden="true"
|
|
loading="lazy"
|
|
className="h-5 w-5 shrink-0 rounded-[3px] bg-white object-contain p-0.5"
|
|
/>
|
|
) : (
|
|
<TransitIcon className="h-5 w-5 shrink-0 text-warm-400 dark:text-warm-500" />
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-sm font-medium text-warm-900 dark:text-warm-100">
|
|
{station.name}
|
|
</div>
|
|
<div className="text-xs text-warm-500 dark:text-warm-400">{ts(station.category)}</div>
|
|
</div>
|
|
<span className="shrink-0 text-sm font-semibold tabular-nums text-teal-700 dark:text-teal-400">
|
|
{formatStationDistance(station.distanceKm)}
|
|
</span>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
export default function AreaPane({
|
|
stats,
|
|
globalFeatures,
|
|
loading,
|
|
hexagonId,
|
|
isPostcode = false,
|
|
hexagonLocation,
|
|
filters,
|
|
unfilteredCount,
|
|
statsUseFilters,
|
|
onStatsUseFiltersChange,
|
|
onNavigateToSource,
|
|
travelTimeEntries,
|
|
shareCode,
|
|
isGroupExpanded,
|
|
onToggleGroup,
|
|
scrollTopRef,
|
|
scrollRestoreKey,
|
|
scrollSaveDisabled,
|
|
}: AreaPaneProps) {
|
|
const { t } = useTranslation();
|
|
const propertyCount = stats?.count;
|
|
const activeFilterCount = Object.keys(filters).length + (travelTimeEntries?.length ?? 0);
|
|
const filtersActive = activeFilterCount > 0;
|
|
const filteredStatsEmpty = filtersActive && statsUseFilters && stats?.count === 0;
|
|
const showFlipToggleCallout = filteredStatsEmpty && unfilteredCount !== 0;
|
|
const activeFilterNames = useMemo(() => new Set(Object.keys(filters)), [filters]);
|
|
const activeAmenityFeatureNames = useMemo(
|
|
() => getActiveAmenityFilterFeatureNames(filters),
|
|
[filters]
|
|
);
|
|
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
|
|
const paneFeatureGroups = useMemo<FeatureGroup[]>(
|
|
() =>
|
|
featureGroups
|
|
.map((group) => {
|
|
if (group.name !== 'Amenities') return group;
|
|
|
|
const features = group.features.filter((feature) => {
|
|
if (isPoiFilterFeatureName(feature.name)) {
|
|
return activeAmenityFeatureNames.has(feature.name);
|
|
}
|
|
return activeFilterNames.has(feature.name);
|
|
});
|
|
|
|
return { ...group, features };
|
|
})
|
|
.filter((group) => group.name !== 'Amenities' || group.features.length > 0),
|
|
[activeAmenityFeatureNames, activeFilterNames, featureGroups]
|
|
);
|
|
const displayFeatureGroups = useMemo<FeatureGroup[]>(() => {
|
|
if (
|
|
!hexagonLocation ||
|
|
paneFeatureGroups.some((group) => STATION_GROUP_NAMES.has(group.name))
|
|
) {
|
|
return paneFeatureGroups;
|
|
}
|
|
|
|
return [{ name: STATION_GROUP_NAME, features: [] }, ...paneFeatureGroups];
|
|
}, [paneFeatureGroups, hexagonLocation]);
|
|
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
|
const { scrollRef, onScroll } = useRetainedScrollTop<HTMLDivElement>({
|
|
restoreKey: scrollRestoreKey ?? hexagonId,
|
|
scrollTopRef,
|
|
suspendSave: scrollSaveDisabled ?? (loading && stats == null),
|
|
});
|
|
|
|
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]);
|
|
|
|
// Crime-by-year series is keyed in the API by the bare crime type (e.g. "Burglary").
|
|
// We also index by the configured feature name (with " (avg/yr)" suffix) so the
|
|
// metric-row renderer can look it up using the feature name it already has.
|
|
const crimeByYearByFeatureName = useMemo(() => {
|
|
const map = new Map<string, NonNullable<HexagonStatsResponse['crime_by_year']>[number]>();
|
|
for (const entry of stats?.crime_by_year ?? []) {
|
|
map.set(entry.name, entry);
|
|
map.set(`${entry.name} (avg/yr)`, entry);
|
|
}
|
|
return map;
|
|
}, [stats]);
|
|
|
|
const globalFeatureByName = useMemo(
|
|
() => new Map(globalFeatures.map((f) => [f.name, f])),
|
|
[globalFeatures]
|
|
);
|
|
const travelEntryByField = useMemo(() => {
|
|
const map = new Map<string, TravelTimeEntry>();
|
|
for (const entry of travelTimeEntries ?? []) {
|
|
map.set(travelFieldKey(entry), entry);
|
|
}
|
|
return map;
|
|
}, [travelTimeEntries]);
|
|
const filterExclusions = stats?.filter_exclusions ?? [];
|
|
|
|
const getExclusionLabel = (exclusion: FilterExclusion) => {
|
|
const travelEntry = travelEntryByField.get(exclusion.name);
|
|
if (travelEntry) return t('areaPane.travelTo', { destination: travelEntry.label });
|
|
return ts(exclusion.name);
|
|
};
|
|
|
|
const formatExclusionValue = (exclusion: FilterExclusion, value: number) => {
|
|
if (exclusion.kind === 'travel') return `${Math.round(value)} ${t('common.minute')}`;
|
|
return formatFilterValue(value, filterValueFormat(globalFeatureByName.get(exclusion.name)));
|
|
};
|
|
|
|
const getExclusionAdjustment = (exclusion: FilterExclusion) => {
|
|
if (exclusion.direction === 'missing_value') {
|
|
return t('areaPane.missingFilterValue');
|
|
}
|
|
if (exclusion.direction === 'allow_value') {
|
|
return t('areaPane.allowCategory', { value: ts(exclusion.category ?? '') });
|
|
}
|
|
if (exclusion.value == null) return '';
|
|
const value = formatExclusionValue(exclusion, exclusion.value);
|
|
return exclusion.direction === 'lower_min'
|
|
? t('areaPane.lowerMinTo', { value })
|
|
: t('areaPane.raiseMaxTo', { value });
|
|
};
|
|
|
|
if (!hexagonId) {
|
|
return (
|
|
<EmptyState
|
|
icon={<InfoIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
|
|
title={t('common.noAreaSelected')}
|
|
description={t('common.noAreaSelectedDesc')}
|
|
centered
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="relative flex h-full flex-col">
|
|
<IndeterminateProgressBar show={loading && stats != null} />
|
|
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto">
|
|
<div className="border-b border-warm-200 bg-white dark:border-navy-700 dark:bg-navy-950">
|
|
<div className="space-y-3 p-3">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<h2 className="truncate text-base font-semibold text-warm-900 dark:text-warm-100">
|
|
{isPostcode ? hexagonId : t('areaPane.areaOverview')}
|
|
</h2>
|
|
{loading && (
|
|
<span className="h-3 w-3 shrink-0 rounded-full border-2 border-teal-600 border-t-transparent dark:border-teal-400 dark:border-t-transparent animate-spin" />
|
|
)}
|
|
</div>
|
|
<p className="mt-0.5 text-xs leading-snug text-warm-500 dark:text-warm-400">
|
|
{t('areaPane.statsFor', {
|
|
type: isPostcode
|
|
? t('common.postcode').toLowerCase()
|
|
: t('common.area').toLowerCase(),
|
|
})}
|
|
</p>
|
|
</div>
|
|
<div className="shrink-0 text-right">
|
|
<div className="text-lg font-semibold tabular-nums leading-none text-navy-950 dark:text-warm-50">
|
|
{propertyCount == null ? '...' : propertyCount.toLocaleString()}
|
|
</div>
|
|
<div className="mt-0.5 text-xs font-medium text-warm-500 dark:text-warm-400">
|
|
{t('common.propertiesPlural')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded border border-warm-200 bg-warm-50 px-2.5 py-2 dark:border-navy-700 dark:bg-navy-900">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<span className="text-xs font-semibold text-warm-700 dark:text-warm-200">
|
|
{t('areaPane.statsBasis')}
|
|
</span>
|
|
<div className="grid min-w-0 flex-1 basis-52 grid-cols-2 rounded-md bg-warm-200 p-0.5 dark:bg-navy-800">
|
|
<button
|
|
type="button"
|
|
disabled={!filtersActive}
|
|
aria-pressed={statsUseFilters && filtersActive}
|
|
onClick={() => onStatsUseFiltersChange(true)}
|
|
className={`min-w-0 rounded px-2 py-1 text-center text-xs font-medium leading-tight break-words ${
|
|
statsUseFilters && filtersActive
|
|
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
|
|
: 'text-warm-600 hover:text-warm-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-warm-400 dark:hover:text-warm-100'
|
|
}`}
|
|
>
|
|
{t('areaPane.matchingFiltersOption')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-pressed={!statsUseFilters || !filtersActive}
|
|
onClick={() => onStatsUseFiltersChange(false)}
|
|
className={`min-w-0 rounded px-2 py-1 text-center text-xs font-medium leading-tight break-words ${
|
|
!statsUseFilters || !filtersActive
|
|
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
|
|
: 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
|
|
}`}
|
|
>
|
|
{t('areaPane.allPropertiesOption')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<p className="mt-1.5 text-xs leading-snug text-warm-500 dark:text-warm-400">
|
|
{filtersActive
|
|
? statsUseFilters
|
|
? t('areaPane.filtersAffectStats', { count: activeFilterCount })
|
|
: t('areaPane.filtersIgnoredForStats')
|
|
: t('areaPane.noFiltersAffectStats')}
|
|
</p>
|
|
</div>
|
|
|
|
{showFlipToggleCallout && (
|
|
<div className="mt-2 rounded border border-amber-200 bg-amber-50 px-2.5 py-2 text-xs leading-snug text-amber-900 dark:border-amber-800/70 dark:bg-amber-950/40 dark:text-amber-100">
|
|
<p className="font-semibold">{t('areaPane.filteredStatsEmpty')}</p>
|
|
<p className="mt-1">
|
|
{unfilteredCount != null
|
|
? t('areaPane.showAllStatsHint', { count: unfilteredCount })
|
|
: t('areaPane.showAllStatsFallback')}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => onStatsUseFiltersChange(false)}
|
|
className="mt-2 rounded bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
|
|
>
|
|
{t('areaPane.showAllStats')}
|
|
</button>
|
|
{filterExclusions.length > 0 && (
|
|
<div className="mt-2 border-t border-amber-200 pt-2 dark:border-amber-800/70">
|
|
<p className="font-semibold">{t('areaPane.closestBlockingFilters')}</p>
|
|
<ol className="mt-1.5 space-y-1.5">
|
|
{filterExclusions.map((exclusion) => (
|
|
<li
|
|
key={`${exclusion.kind}:${exclusion.name}:${exclusion.direction}:${exclusion.category ?? ''}`}
|
|
className="rounded bg-white/70 px-2 py-1.5 dark:bg-navy-950/40"
|
|
>
|
|
<div className="break-words font-medium leading-snug">
|
|
{getExclusionLabel(exclusion)}
|
|
</div>
|
|
<p className="mt-0.5 text-amber-800/80 dark:text-amber-100/80">
|
|
{getExclusionAdjustment(exclusion)}
|
|
</p>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{hexagonLocation && stats && (
|
|
<ExternalSearchLinks location={hexagonLocation} filters={filters} />
|
|
)}
|
|
{(() => {
|
|
const journeyPostcode = isPostcode ? hexagonId : stats?.central_postcode;
|
|
return journeyPostcode && travelTimeEntries && travelTimeEntries.length > 0 ? (
|
|
<JourneyInstructions
|
|
postcode={journeyPostcode}
|
|
entries={travelTimeEntries}
|
|
label={!isPostcode ? journeyPostcode : undefined}
|
|
shareCode={shareCode}
|
|
/>
|
|
) : null;
|
|
})()}
|
|
{loading && !stats ? (
|
|
<LoadingSkeleton />
|
|
) : stats ? (
|
|
<div>
|
|
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
|
|
{stats.price_history &&
|
|
(() => {
|
|
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
|
|
return uniqueYears.size > 1 ? (
|
|
<div className="mx-3 mt-2 bg-warm-50 dark:bg-warm-800 rounded p-2">
|
|
<span className="text-xs text-warm-700 dark:text-warm-300">
|
|
{t('areaPane.priceHistory')}
|
|
</span>
|
|
<PriceHistoryChart points={stats.price_history} />
|
|
</div>
|
|
) : null;
|
|
})()}
|
|
{displayFeatureGroups.map((group) => {
|
|
const showNearbyStations =
|
|
hexagonLocation != null && STATION_GROUP_NAMES.has(group.name);
|
|
const hasData = group.features.some(
|
|
(feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
|
|
);
|
|
const expanded = isGroupExpanded(group.name);
|
|
if (!hasData && !showNearbyStations && stats.count === 0) return null;
|
|
|
|
const stackedCharts = STACKED_GROUPS[group.name];
|
|
const stackedEnumCharts = STACKED_ENUM_GROUPS[group.name];
|
|
|
|
const stackedEnumFeatureNames = new Set<string>(
|
|
stackedEnumCharts?.flatMap((c) =>
|
|
[c.feature, ...c.components].filter((s): s is string => Boolean(s))
|
|
) ?? []
|
|
);
|
|
|
|
return (
|
|
<div key={group.name}>
|
|
<CollapsibleGroupHeader
|
|
name={group.name}
|
|
expanded={expanded}
|
|
onToggle={() => onToggleGroup(group.name)}
|
|
className="area-pane-group-header sticky top-0 z-10 bg-white px-3 pb-1.5 pt-4 text-[11px] font-bold uppercase tracking-wide text-warm-500 hover:bg-warm-50 dark:bg-navy-950 dark:text-warm-400 dark:hover:bg-navy-900"
|
|
/>
|
|
{expanded && (
|
|
<div className="divide-y divide-warm-100 px-3 py-1 dark:divide-navy-800">
|
|
{showNearbyStations && <NearbyStationsCard location={hexagonLocation} />}
|
|
{stackedCharts?.map((chart) => {
|
|
const segments = chart.components
|
|
.map((name) => ({
|
|
name,
|
|
value: numericByName.get(name)?.mean ?? 0,
|
|
}))
|
|
.filter((s) => s.value > 0);
|
|
|
|
const isPercentageComposition = chart.unit === '%' && !chart.feature;
|
|
const displaySegments = isPercentageComposition
|
|
? normalizePercentageSegments(segments)
|
|
: segments;
|
|
|
|
const aggregateStats = chart.feature
|
|
? numericByName.get(chart.feature)
|
|
: undefined;
|
|
const total = aggregateStats
|
|
? aggregateStats.mean
|
|
: displaySegments.reduce((sum, s) => sum + s.value, 0);
|
|
|
|
// Use rateFeature (e.g. per-1k) for display if available
|
|
const rateStats = chart.rateFeature
|
|
? numericByName.get(chart.rateFeature)
|
|
: undefined;
|
|
const displayValue = isPercentageComposition
|
|
? 100
|
|
: rateStats
|
|
? rateStats.mean
|
|
: total;
|
|
|
|
// Use rateFeature for info popup and national average when available
|
|
const infoFeatureName = chart.rateFeature ?? chart.feature;
|
|
const featureMeta = infoFeatureName
|
|
? globalFeatureByName.get(infoFeatureName)
|
|
: undefined;
|
|
|
|
const globalMean = featureMeta?.histogram
|
|
? calculateHistogramMean(featureMeta.histogram)
|
|
: undefined;
|
|
|
|
if (total === 0) return null;
|
|
|
|
const crimeSeries = chart.feature
|
|
? crimeByYearByFeatureName.get(chart.feature)
|
|
: undefined;
|
|
|
|
return (
|
|
<div
|
|
key={ts(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: ts(chart.label) }}
|
|
onShowInfo={setInfoFeature}
|
|
className="mr-2"
|
|
wrap
|
|
/>
|
|
) : (
|
|
<span className="mr-2 min-w-0 break-words text-xs leading-snug text-warm-700 dark:text-warm-300">
|
|
{ts(chart.label)}
|
|
</span>
|
|
)}
|
|
<div className="text-right shrink-0">
|
|
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
|
{formatValue(displayValue)}
|
|
{chart.unit ? ` ${chart.unit}` : ''}
|
|
</span>
|
|
{globalMean != null && (
|
|
<div className="text-[10px] text-warm-400 dark:text-warm-500 whitespace-nowrap">
|
|
{t('areaPane.nationalAvg')}: {formatValue(globalMean)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<StackedBarChart
|
|
segments={displaySegments}
|
|
total={total}
|
|
colorMap={
|
|
chart.label === 'Political vote share'
|
|
? PARTY_FEATURE_COLORS
|
|
: STACKED_SEGMENT_COLORS
|
|
}
|
|
/>
|
|
{crimeSeries && crimeSeries.points.length > 1 && (
|
|
<div className="mt-2">
|
|
<CrimeYearChart
|
|
points={crimeSeries.points}
|
|
latestAvailableYear={stats?.crime_latest_year}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
{(() => {
|
|
const stackedFeatureNames = new Set<string>(
|
|
stackedCharts?.flatMap((c) =>
|
|
[c.feature, c.rateFeature, ...c.components].filter((s): s is string =>
|
|
Boolean(s)
|
|
)
|
|
) ?? []
|
|
);
|
|
return group.features
|
|
.filter(
|
|
(f) =>
|
|
!stackedFeatureNames.has(f.name) &&
|
|
!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;
|
|
const crimeSeries = crimeByYearByFeatureName.get(feature.name);
|
|
|
|
return (
|
|
<MetricRow
|
|
key={feature.name}
|
|
label={
|
|
<MetricFeatureLabel
|
|
feature={feature}
|
|
onShowInfo={setInfoFeature}
|
|
aboutLabel={t('filters.aboutData')}
|
|
/>
|
|
}
|
|
chart={
|
|
crimeSeries && crimeSeries.points.length > 1 ? (
|
|
<CrimeYearChart
|
|
points={crimeSeries.points}
|
|
latestAvailableYear={stats?.crime_latest_year}
|
|
/>
|
|
) : (
|
|
numericStats.histogram &&
|
|
(globalHistogram ? (
|
|
<DualHistogram
|
|
localCounts={numericStats.histogram.counts}
|
|
globalCounts={globalHistogram.counts}
|
|
p1={numericStats.histogram.p1}
|
|
p99={numericStats.histogram.p99}
|
|
globalMean={globalMean}
|
|
meanLabel={t('areaPane.nationalAvg')}
|
|
formatLabel={(v) =>
|
|
formatFilterValue(
|
|
v,
|
|
feature.suffix === '%'
|
|
? { raw: feature.raw, suffix: feature.suffix }
|
|
: feature.raw
|
|
)
|
|
}
|
|
integerAxisLabels={feature.step === 1}
|
|
compact
|
|
/>
|
|
) : (
|
|
<DualHistogram
|
|
localCounts={numericStats.histogram.counts}
|
|
globalCounts={numericStats.histogram.counts}
|
|
p1={numericStats.histogram.p1}
|
|
p99={numericStats.histogram.p99}
|
|
formatLabel={(v) =>
|
|
formatFilterValue(
|
|
v,
|
|
feature.suffix === '%'
|
|
? { raw: feature.raw, suffix: feature.suffix }
|
|
: feature.raw
|
|
)
|
|
}
|
|
integerAxisLabels={feature.step === 1}
|
|
compact
|
|
/>
|
|
))
|
|
)
|
|
}
|
|
value={formatValue(numericStats.mean, feature)}
|
|
valueTitle={
|
|
globalMean != null
|
|
? `${t('areaPane.nationalAvg')}: ${formatValue(globalMean)}`
|
|
: undefined
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (enumStats) {
|
|
const globalFeature = globalFeatureByName.get(feature.name);
|
|
return (
|
|
<div
|
|
key={feature.name}
|
|
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
|
>
|
|
<FeatureLabel
|
|
feature={feature}
|
|
onShowInfo={setInfoFeature}
|
|
wrap
|
|
/>
|
|
<EnumBarChart
|
|
counts={enumStats.counts}
|
|
globalCounts={globalFeature?.counts}
|
|
featureName={feature.name}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
});
|
|
})()}
|
|
{stackedEnumCharts?.map((chart) => {
|
|
const featureMeta = chart.feature
|
|
? globalFeatureByName.get(chart.feature)
|
|
: undefined;
|
|
|
|
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;
|
|
|
|
return (
|
|
<div
|
|
key={ts(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}
|
|
onShowInfo={setInfoFeature}
|
|
className="mr-2"
|
|
wrap
|
|
/>
|
|
) : (
|
|
<span className="mr-2 min-w-0 break-words text-xs leading-snug text-warm-700 dark:text-warm-300">
|
|
{ts(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]])
|
|
)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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={ts(chart.label)}
|
|
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
|
>
|
|
<div className="mb-1.5">
|
|
{featureMeta ? (
|
|
<FeatureLabel
|
|
feature={{ ...featureMeta, name: ts(chart.label) }}
|
|
onShowInfo={setInfoFeature}
|
|
wrap
|
|
/>
|
|
) : (
|
|
<span className="text-xs text-warm-700 dark:text-warm-300">
|
|
{ts(chart.label)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<StackedEnumChart
|
|
components={components}
|
|
valueOrder={chart.valueOrder}
|
|
valueColors={chart.valueColors}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{infoFeature && (
|
|
<FeatureInfoPopup
|
|
feature={infoFeature}
|
|
onClose={() => setInfoFeature(null)}
|
|
onNavigateToSource={onNavigateToSource}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|