all is well
Some checks failed
Build and publish Docker image / build-and-push (push) Failing after 7m0s
CI / Check (push) Failing after 7m9s

This commit is contained in:
Andras Schmelczer 2026-05-17 17:20:19 +01:00
parent eac1bd0d13
commit 2f149503bb
53 changed files with 1543 additions and 354 deletions

View file

@ -1,14 +1,16 @@
import { useMemo, useState } from 'react';
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,
@ -16,19 +18,22 @@ import {
roundedPercentages,
} from '../../lib/format';
import { groupFeaturesByCategory } from '../../lib/features';
import { getPoiCategoryLogoUrl } from '../../lib/map-utils';
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 ExternalSearchLinks from './ExternalSearchLinks';
import { InfoIcon } from '../ui/icons';
import { InfoIcon, TransitIcon } from '../ui/icons';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { EmptyState } from '../ui/EmptyState';
@ -54,6 +59,9 @@ interface AreaPaneProps {
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[] {
@ -75,6 +83,136 @@ function filterValueFormat(feature?: FeatureMeta) {
};
}
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 truncate text-[13px] font-medium leading-5 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-center 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,
@ -91,6 +229,9 @@ export default function AreaPane({
shareCode,
isGroupExpanded,
onToggleGroup,
scrollTopRef,
scrollRestoreKey,
scrollSaveDisabled,
}: AreaPaneProps) {
const { t } = useTranslation();
const propertyCount = stats?.count;
@ -99,7 +240,19 @@ export default function AreaPane({
const filteredStatsEmpty = filtersActive && statsUseFilters && stats?.count === 0;
const showFlipToggleCallout = filteredStatsEmpty && unfilteredCount !== 0;
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
const displayFeatureGroups = useMemo<FeatureGroup[]>(() => {
if (!hexagonLocation || featureGroups.some((group) => STATION_GROUP_NAMES.has(group.name))) {
return featureGroups;
}
return [{ name: STATION_GROUP_NAME, features: [] }, ...featureGroups];
}, [featureGroups, 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();
@ -164,7 +317,7 @@ export default function AreaPane({
<>
<div className="relative flex h-full flex-col">
<IndeterminateProgressBar show={loading && stats != null} />
<div className="flex-1 overflow-y-auto">
<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">
@ -300,20 +453,22 @@ export default function AreaPane({
{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>
)}
{featureGroups.map((group) => {
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)
);
if (!hasData) return null;
if (!hasData && !showNearbyStations) return null;
const stackedCharts = STACKED_GROUPS[group.name];
const stackedEnumCharts = STACKED_ENUM_GROUPS[group.name];
@ -332,10 +487,11 @@ export default function AreaPane({
name={group.name}
expanded={expanded}
onToggle={() => onToggleGroup(group.name)}
className="px-3 py-2.5 text-sm font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 z-10 hover:bg-warm-100 dark:hover:bg-warm-800"
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="px-3 py-2 space-y-3">
<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) => ({
@ -445,21 +601,17 @@ export default function AreaPane({
: undefined;
return (
<div
<MetricRow
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline">
<FeatureLabel
label={
<MetricFeatureLabel
feature={feature}
onShowInfo={setInfoFeature}
className="mr-2"
aboutLabel={t('filters.aboutData')}
/>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(numericStats.mean, feature)}
</span>
</div>
{numericStats.histogram &&
}
chart={
numericStats.histogram &&
(globalHistogram ? (
<DualHistogram
localCounts={numericStats.histogram.counts}
@ -476,6 +628,8 @@ export default function AreaPane({
: feature.raw
)
}
integerAxisLabels={feature.step === 1}
compact
/>
) : (
<DualHistogram
@ -491,9 +645,18 @@ export default function AreaPane({
: feature.raw
)
}
integerAxisLabels={feature.step === 1}
compact
/>
))}
</div>
))
}
value={formatValue(numericStats.mean, feature)}
valueTitle={
globalMean != null
? `${t('areaPane.nationalAvg')}: ${formatValue(globalMean)}`
: undefined
}
/>
);
}

View file

@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { compactHistogramLabel } from './DualHistogram';
describe('compactHistogramLabel', () => {
it('rounds low-cardinality count labels to integers', () => {
const fmt = (value: number) => value.toFixed(2);
const labels = [0, 0.99, 2.98, 4.96, 5.95].map((center, index) =>
compactHistogramLabel(index, 5, 0, 5.95, center, fmt, true)
);
expect(labels).toEqual(['0', '1', '3', '5', '6+']);
});
it('labels the first integer count bucket as zero when it means below one', () => {
const fmt = (value: number) => value.toFixed(2);
expect(compactHistogramLabel(0, 5, 0.99, 5.95, 0.99, fmt, true)).toBe('0');
});
it('keeps fractional labels when integer labels are not requested', () => {
const fmt = (value: number) => value.toFixed(2);
expect(compactHistogramLabel(1, 5, 0, 5.95, 0.99, fmt, false)).toBe('0.99');
});
});

View file

@ -30,6 +30,42 @@ function pickTicks(min: number, max: number, count: number): number[] {
return ticks;
}
function isLowCardinalityHistogram(counts: number[], p1: number, p99: number): boolean {
return counts.length > 0 && counts.length <= 10 && p99 > p1 && p99 - p1 <= 10;
}
export function compactHistogramLabel(
index: number,
barCount: number,
p1: number,
p99: number,
center: number,
formatLabel: (value: number) => string,
integerLabels = false
): string {
const formatAxisValue = (value: number) =>
integerLabels ? Math.round(value).toLocaleString() : formatLabel(value);
if (barCount <= 1) return formatAxisValue(center);
const middleBins = barCount - 2;
if (index === 0) {
if (!integerLabels) return `<${formatLabel(p1)}`;
const firstBoundary = Math.ceil(p1);
return firstBoundary <= 1 ? '0' : `<${firstBoundary.toLocaleString()}`;
}
if (index === barCount - 1) {
if (!integerLabels) return `${formatLabel(p99)}+`;
return `${Math.ceil(p99).toLocaleString()}+`;
}
const middleWidth = middleBins > 0 ? (p99 - p1) / middleBins : 0;
if (Math.abs(middleWidth - 1) < 0.001) {
return formatAxisValue(p1 + index - 1);
}
return formatAxisValue(center);
}
export function DualHistogram({
localCounts,
globalCounts,
@ -38,6 +74,8 @@ export function DualHistogram({
globalMean,
meanLabel,
formatLabel,
compact = false,
integerAxisLabels = false,
}: {
localCounts: number[];
globalCounts: number[];
@ -46,9 +84,15 @@ export function DualHistogram({
globalMean?: number;
meanLabel?: string;
formatLabel?: (value: number) => string;
compact?: boolean;
integerAxisLabels?: boolean;
}) {
const { t } = useTranslation();
const targetBars = 25;
const showCompactAxisLabels =
compact &&
isLowCardinalityHistogram(localCounts, p1, p99) &&
isLowCardinalityHistogram(globalCounts, p1, p99);
const targetBars = compact ? (showCompactAxisLabels ? localCounts.length : 16) : 25;
const localBars = downsampleBars(localCounts, targetBars);
const globalBars = downsampleBars(globalCounts, targetBars);
@ -59,6 +103,8 @@ export function DualHistogram({
const fmt =
formatLabel ?? ((v: number) => (Number.isInteger(v) ? v.toLocaleString() : v.toFixed(1)));
if (barCount === 0) return null;
// Compute center value for each bar.
// Bar 0 = low outlier, bars 1..n-2 = middle (p1 to p99), bar n-1 = high outlier.
const middleBins = Math.max(barCount - 2, 0);
@ -97,6 +143,60 @@ export function DualHistogram({
? { right: 0 }
: { left: '50%', transform: 'translateX(-50%)' };
if (compact) {
const axisLabels = showCompactAxisLabels
? barCenters.map((center, index) =>
compactHistogramLabel(index, barCount, p1, p99, center, fmt, integerAxisLabels)
)
: [];
const chartTitle = [
`${fmt(p1)} - ${fmt(p99)}`,
globalMean != null ? `${meanLabel ?? t('areaPane.nationalAvg')}: ${fmt(globalMean)}` : null,
]
.filter(Boolean)
.join('\n');
return (
<div className={showCompactAxisLabels ? 'h-10' : 'h-7'} title={chartTitle}>
<div
className={`${showCompactAxisLabels ? 'h-7' : 'h-full'} relative flex items-end gap-[2px]`}
>
{Array.from({ length: barCount }).map((_, index) => {
const globalHeight = (globalBars[index] / globalMax) * 100;
const localHeight = (localBars[index] / localMax) * 100;
return (
<div key={index} className="relative flex h-full min-w-[2px] flex-1 items-end">
<div
className="absolute bottom-0 left-0 right-0 rounded-t-[2px] bg-warm-300/45 dark:bg-warm-600/50"
style={{ height: `${Math.max(globalHeight, globalBars[index] > 0 ? 8 : 0)}%` }}
/>
{localBars[index] > 0 && (
<div
className="absolute bottom-0 left-[22%] right-[22%] rounded-t-[2px] bg-teal-600 dark:bg-teal-400"
style={{ height: `${Math.max(localHeight, 12)}%` }}
/>
)}
</div>
);
})}
</div>
{showCompactAxisLabels && (
<div className="mt-0.5 flex gap-[2px]">
{axisLabels.map((label, index) => (
<span
key={`${label}-${index}`}
className="min-w-[2px] flex-1 truncate text-center text-[8px] font-medium leading-none text-warm-500 dark:text-warm-400"
title={label}
>
{label}
</span>
))}
</div>
)}
</div>
);
}
return (
<div className="mt-1">
<div className={showMeanMarker ? 'relative pt-5' : 'relative'}>
@ -152,35 +252,29 @@ export function DualHistogram({
function SkeletonHistogram() {
return (
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2 animate-pulse">
<div className="flex justify-between items-baseline">
<div className="h-3 w-24 bg-warm-200 dark:bg-warm-700 rounded" />
<div className="h-3 w-10 bg-warm-200 dark:bg-warm-700 rounded" />
</div>
<div className="flex items-end gap-px h-10 mt-2">
{Array.from({ length: 15 }).map((_, i) => (
<div className="grid min-h-10 animate-pulse grid-cols-[minmax(0,1fr)_6.5rem_minmax(3.5rem,auto)] items-center gap-3 py-1.5">
<div className="h-3 w-24 rounded bg-warm-200 dark:bg-warm-700" />
<div className="flex h-7 items-end gap-[2px]">
{Array.from({ length: 12 }).map((_, i) => (
<div
key={i}
className="flex-1 bg-warm-200 dark:bg-warm-700 rounded-t-sm min-w-[2px]"
style={{ height: `${20 + Math.sin(i * 0.7) * 30 + 30}%` }}
className="min-w-[2px] flex-1 rounded-t-[2px] bg-warm-200 dark:bg-warm-700"
style={{ height: `${22 + Math.sin(i * 0.7) * 28 + 30}%` }}
/>
))}
</div>
<div className="flex justify-between mt-1">
<div className="h-2 w-6 bg-warm-200 dark:bg-warm-700 rounded" />
<div className="h-2 w-6 bg-warm-200 dark:bg-warm-700 rounded" />
</div>
<div className="h-3 w-10 justify-self-end rounded bg-warm-200 dark:bg-warm-700" />
</div>
);
}
export function LoadingSkeleton() {
return (
<div className="p-3 space-y-4">
<div className="space-y-4 p-3">
{[0, 1, 2].map((groupIdx) => (
<div key={groupIdx}>
<div className="h-3 w-20 bg-warm-200 dark:bg-warm-700 rounded animate-pulse mb-2" />
<div className="space-y-3">
<div className="mb-2 h-3 w-20 animate-pulse rounded bg-warm-200 dark:bg-warm-700" />
<div className="divide-y divide-warm-100 dark:divide-navy-800">
{Array.from({ length: groupIdx === 0 ? 3 : 2 }).map((_, i) => (
<SkeletonHistogram key={i} />
))}

View file

@ -1,16 +1,34 @@
import { ts } from '../../i18n/server';
import { getEnumValueColor } from '../../lib/consts';
function shortenAxisLabel(label: string, total: number): string {
if (label.length <= 3) return label;
const parts = label.split(/[\s/&-]+/).filter(Boolean);
if (parts.length > 1) {
return parts
.map((part) => Array.from(part)[0])
.join('')
.slice(0, 3);
}
return Array.from(label)
.slice(0, total <= 5 ? 3 : 2)
.join('');
}
export default function EnumBarChart({
counts,
globalCounts,
featureName,
compact = false,
}: {
counts: Record<string, number>;
globalCounts?: Record<string, number>;
featureName: string;
compact?: boolean;
}) {
const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA);
if (entries.length === 0) return null;
const localTotal = entries.reduce((sum, [, c]) => sum + c, 0);
// When global counts are available, normalize both to percentages for comparison
@ -28,6 +46,71 @@ export default function EnumBarChart({
// Fallback to raw count scaling when no global data
const maxCount = Math.max(...entries.map(([, count]) => count), 1);
if (compact) {
const title = entries
.map(([label, count]) => {
const localPct = localTotal > 0 ? (count / localTotal) * 100 : 0;
const globalPct =
hasGlobal && globalTotal > 0 ? ((globalCounts[label] ?? 0) / globalTotal) * 100 : null;
return `${ts(label)}: ${count.toLocaleString()} (${localPct.toFixed(1)}%)${
globalPct != null ? ` / ${globalPct.toFixed(1)}%` : ''
}`;
})
.join('\n');
return (
<div className="h-10" title={title}>
<div className="flex h-7 items-end gap-[2px]">
{entries.map(([label, count]) => {
const localPct = localTotal > 0 ? count / localTotal : 0;
const globalPct = hasGlobal ? (globalCounts[label] ?? 0) / globalTotal : 0;
const localHeight = hasGlobal
? maxPct > 0
? (localPct / maxPct) * 100
: 0
: (count / maxCount) * 100;
const globalHeight = hasGlobal && maxPct > 0 ? (globalPct / maxPct) * 100 : 0;
const color = getEnumValueColor(featureName, label);
return (
<div key={label} className="relative flex h-full min-w-[3px] flex-1 items-end">
{hasGlobal && (
<div
className="absolute bottom-0 left-0 right-0 rounded-t-[2px] bg-warm-300/45 dark:bg-warm-600/50"
style={{ height: `${Math.max(globalHeight, globalPct > 0 ? 8 : 0)}%` }}
/>
)}
{count > 0 && (
<div
className="absolute bottom-0 left-[18%] right-[18%] rounded-t-[2px]"
style={{
height: `${Math.max(localHeight, 12)}%`,
backgroundColor: `rgb(${color[0]},${color[1]},${color[2]})`,
}}
/>
)}
</div>
);
})}
</div>
<div className="mt-0.5 flex gap-[2px]">
{entries.map(([label]) => {
const translated = ts(label);
return (
<span
key={label}
className="min-w-[3px] flex-1 truncate text-center text-[8px] font-medium leading-none text-warm-500 dark:text-warm-400"
title={translated}
>
{shortenAxisLabel(translated, entries.length)}
</span>
);
})}
</div>
</div>
);
}
return (
<div className="space-y-1 mt-1">
{entries.map(([label, count]) => {

View file

@ -3,35 +3,18 @@ import { useTranslation } from 'react-i18next';
export default function HistogramLegend() {
const { t } = useTranslation();
return (
<div className="mx-3 mt-3 bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800/50 rounded p-2.5 text-xs">
<div className="space-y-1.5">
<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">
{t('histogramLegend.tealBars')}
</span>{' '}
{t('histogramLegend.tealBarsDesc')}
</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">
{t('histogramLegend.greyBars')}
</span>{' '}
{t('histogramLegend.greyBarsDesc')}
</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">
{t('histogramLegend.dashedLine')}
</span>{' '}
{t('histogramLegend.dashedLineDesc')}
</span>
</div>
<div className="mx-3 mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 rounded border border-warm-200 bg-white px-2.5 py-1.5 text-[10px] text-warm-500 dark:border-navy-800 dark:bg-navy-950/60 dark:text-warm-400">
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2 rounded-[2px] bg-teal-600 dark:bg-teal-400" />
<span className="font-medium text-warm-700 dark:text-warm-200">
{t('histogramLegend.tealBars')}
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2 rounded-[2px] bg-warm-300/70 dark:bg-warm-600/70" />
<span className="font-medium text-warm-700 dark:text-warm-200">
{t('histogramLegend.greyBars')}
</span>
</div>
</div>
);

View file

@ -147,6 +147,8 @@ export default function MapPage({
const pendingCurrentLocationFlyToRef = useRef<{ lat: number; lng: number } | null>(null);
const pendingLocationSearchFlyToRef = useRef<PendingFlyTo | null>(null);
const mobileDrawerPanelRectRef = useRef<DOMRectReadOnly | null>(null);
const areaPaneScrollTopRef = useRef(0);
const propertiesPaneScrollTopRef = useRef(0);
const getMobileMapFlyToOptions = useCallback((): MapFlyToOptions | undefined => {
if (!isMobile) return undefined;
@ -558,6 +560,11 @@ export default function MapPage({
shareCode={shareCode}
isGroupExpanded={isAreaGroupExpanded}
onToggleGroup={toggleAreaGroup}
scrollTopRef={areaPaneScrollTopRef}
scrollRestoreKey={
selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null
}
scrollSaveDisabled={loadingAreaStats && areaStats == null}
/>
</Suspense>
);
@ -570,6 +577,11 @@ export default function MapPage({
loading={loadingProperties}
hexagonId={selectedHexagon?.id || null}
onLoadMore={handleLoadMoreProperties}
scrollTopRef={propertiesPaneScrollTopRef}
scrollRestoreKey={
selectedHexagon ? `${selectedHexagon.type}:${selectedHexagon.id}` : null
}
scrollSaveDisabled={loadingProperties && properties.length === 0}
/>
</Suspense>
);

View file

@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { ts } from '../../i18n/server';
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import { trackEvent } from '../../lib/analytics';
import { POI_CATEGORY_LOGOS } from '../../lib/consts';
import { getPoiCategoryLogoUrl } from '../../lib/map-utils';
import type { POICategoryGroup } from '../../types';
import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput';
@ -188,7 +188,7 @@ export default function POIPane({
<div className="px-3 py-2">
<PillGroup>
{group.categories.map((category) => {
const logo = POI_CATEGORY_LOGOS[category];
const logo = getPoiCategoryLogoUrl(category);
return (
<PillToggle
key={category}

View file

@ -1,8 +1,9 @@
import { useMemo, useState, useEffect } from 'react';
import { useMemo, useState, useEffect, type MutableRefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { Property } from '../../types';
import { formatDuration, formatAge, formatNumber, formatTransactionDate } from '../../lib/format';
import { getNum } from '../../lib/property-fields';
import { useRetainedScrollTop } from '../../hooks/useRetainedScrollTop';
import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput';
import { EmptyState } from '../ui/EmptyState';
@ -17,6 +18,9 @@ interface PropertiesPaneProps {
hexagonId: string | null;
onLoadMore: () => void;
onNavigateToSource?: (slug: string) => void;
scrollTopRef?: MutableRefObject<number>;
scrollRestoreKey?: string | null;
scrollSaveDisabled?: boolean;
}
export function PropertiesPane({
@ -26,10 +30,18 @@ export function PropertiesPane({
hexagonId,
onLoadMore,
onNavigateToSource,
scrollTopRef,
scrollRestoreKey,
scrollSaveDisabled,
}: PropertiesPaneProps) {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [showInfo, setShowInfo] = useState(false);
const { scrollRef, onScroll } = useRetainedScrollTop<HTMLDivElement>({
restoreKey: scrollRestoreKey ?? hexagonId,
scrollTopRef,
suspendSave: scrollSaveDisabled ?? (loading && properties.length === 0),
});
useEffect(() => {
setSearch('');
@ -60,7 +72,7 @@ export function PropertiesPane({
return (
<div className="relative flex h-full flex-col">
<IndeterminateProgressBar show={loading && properties.length > 0} />
<div className="flex-1 overflow-y-auto">
<div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto">
{showInfo && (
<InfoPopup
title={t('propertyCard.propertyData')}

View file

@ -12,6 +12,7 @@ interface StackedBarChartProps {
segments: Segment[];
total: number;
colorMap: Record<string, string>;
compact?: boolean;
}
/** Strip common suffixes/prefixes to produce short legend labels */
@ -28,7 +29,27 @@ function shortenLabel(name: string): string {
.trim();
}
export default function StackedBarChart({ segments, total, colorMap }: StackedBarChartProps) {
function shortenAxisLabel(name: string, total: number): string {
const label = shortenLabel(name);
if (label.length <= 3) return label;
const parts = label.split(/[\s/&-]+/).filter(Boolean);
if (parts.length > 1) {
return parts
.map((part) => Array.from(part)[0])
.join('')
.slice(0, 3);
}
return Array.from(label)
.slice(0, total <= 5 ? 3 : 2)
.join('');
}
export default function StackedBarChart({
segments,
total,
colorMap,
compact = false,
}: StackedBarChartProps) {
const { t } = useTranslation();
const sortedSegments = useMemo(() => [...segments].sort((a, b) => b.value - a.value), [segments]);
const roundedPcts = useMemo(
@ -55,6 +76,53 @@ export default function StackedBarChart({ segments, total, colorMap }: StackedBa
return color;
};
if (compact) {
const maxValue = Math.max(...sortedSegments.map((segment) => segment.value), 1);
const showAxisLabels = sortedSegments.length <= 8;
const title = sortedSegments
.map((segment, i) => {
const label = shortenLabel(ts(segment.name));
return `${label}: ${formatValue(segment.value)} (${roundedPcts[i].toFixed(1)}%)`;
})
.join('\n');
return (
<div className={showAxisLabels ? 'h-10' : 'h-7'} title={title}>
<div className={`${showAxisLabels ? 'h-7' : 'h-full'} flex items-end gap-[2px]`}>
{sortedSegments.map((segment) => {
const height = (segment.value / maxValue) * 100;
return (
<div
key={segment.name}
className="min-w-[3px] flex-1 rounded-t-[2px]"
style={{
height: `${Math.max(height, 12)}%`,
backgroundColor: colorFor(segment.name),
}}
/>
);
})}
</div>
{showAxisLabels && (
<div className="mt-0.5 flex gap-[2px]">
{sortedSegments.map((segment) => {
const label = shortenLabel(ts(segment.name));
return (
<span
key={segment.name}
className="min-w-[3px] flex-1 truncate text-center text-[8px] font-medium leading-none text-warm-500 dark:text-warm-400"
title={label}
>
{shortenAxisLabel(label, sortedSegments.length)}
</span>
);
})}
</div>
)}
</div>
);
}
return (
<div className="space-y-1.5">
{/* Stacked bar */}

View file

@ -7,6 +7,7 @@ interface StackedEnumChartProps {
components: { label: string; stats: EnumFeatureStats }[];
valueOrder: string[];
valueColors: string[];
compact?: boolean;
}
/** Strip common suffixes to produce short row labels */
@ -14,10 +15,24 @@ function shortenLabel(name: string): string {
return name.replace(/ risk$/, '');
}
function shortenAxisLabel(name: string): string {
const label = shortenLabel(name);
if (label.length <= 3) return label;
const parts = label.split(/[\s/&-]+/).filter(Boolean);
if (parts.length > 1) {
return parts
.map((part) => Array.from(part)[0])
.join('')
.slice(0, 3);
}
return Array.from(label).slice(0, 3).join('');
}
export default function StackedEnumChart({
components,
valueOrder,
valueColors,
compact = false,
}: StackedEnumChartProps) {
const { t } = useTranslation();
const visibleRows = components.filter(({ stats }) => {
@ -35,6 +50,63 @@ export default function StackedEnumChart({
);
}
if (compact) {
return (
<div className="divide-y divide-warm-100 dark:divide-navy-800">
{visibleRows.map(({ label, stats }) => {
const counts = valueOrder.map((value) => stats.counts[value] ?? 0);
const total = counts.reduce((a, b) => a + b, 0);
const roundedPcts = roundedPercentages(counts, total, 0);
const title = valueOrder
.map((value, i) => `${ts(value)}: ${counts[i]} (${roundedPcts[i]}%)`)
.join('\n');
return (
<div
key={label}
className="grid min-h-8 grid-cols-[minmax(0,1fr)_6.5rem] items-center gap-3 py-1.5"
>
<span className="truncate text-xs font-medium text-warm-800 dark:text-warm-200">
{shortenLabel(ts(label))}
</span>
<div
className="flex h-5 overflow-hidden rounded-sm bg-warm-200 dark:bg-warm-700"
title={title}
>
{valueOrder.map((value, i) => {
const count = counts[i];
const pct = (count / total) * 100;
if (pct < 0.5) return null;
return (
<div
key={value}
className="h-full"
style={{
width: `${pct}%`,
backgroundColor: valueColors[i],
}}
/>
);
})}
</div>
</div>
);
})}
<div className="ml-auto grid w-[6.5rem] grid-flow-col auto-cols-fr gap-1 pt-1">
{valueOrder.map((value) => (
<span
key={value}
className="truncate text-center text-[8px] font-medium leading-none text-warm-500 dark:text-warm-400"
title={ts(value)}
>
{shortenAxisLabel(ts(value))}
</span>
))}
</div>
</div>
);
}
return (
<div className="space-y-1.5">
{visibleRows.map(({ label, stats }) => {

View file

@ -110,7 +110,7 @@ export function ActiveFiltersPanel({
>
<button
onClick={onToggleCollapsed}
className="shrink-0 flex items-center justify-between border-y border-l-4 border-teal-300 border-l-teal-600 bg-teal-100 px-3 py-3 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:border-l-teal-300 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
className="shrink-0 flex items-center justify-between border-y border-teal-300 bg-teal-100 px-3 py-3 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
>
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">

View file

@ -110,7 +110,7 @@ export function AddFilterPanel({
>
<button
onClick={onToggleCollapsed}
className="shrink-0 flex items-center justify-between border-y border-l-4 border-teal-300 border-l-teal-600 bg-teal-100 px-3 py-3 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:border-l-teal-300 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
className="shrink-0 flex items-center justify-between border-y border-teal-300 bg-teal-100 px-3 py-3 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
>
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">
{t('filters.addFilter')}
@ -122,8 +122,8 @@ export function AddFilterPanel({
</button>
{(!collapsed || !isLicensed) && (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{!collapsed && (
{!collapsed && (
<div className="min-h-0 flex-1 overflow-y-auto">
<FeatureBrowser
availableFeatures={availableFeatures}
allFeatures={allFeatures}
@ -136,37 +136,37 @@ export function AddFilterPanel({
travelTimeEntries={travelTimeEntries}
onAddTravelTimeEntry={onAddTravelTimeEntry}
/>
)}
{!isLicensed && (
<div className="mt-auto shrink-0 flex flex-col items-center px-5 pt-4 pb-0 border-t border-warm-200 dark:border-warm-700">
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-relaxed mb-1">
{t('filters.upgradePrompt')}
</p>
<p className="text-xs text-warm-400 dark:text-warm-500 text-center mb-4">
{t('filters.oneTimeLifetime')}
</p>
<button
onClick={onUpgradeClick}
className="px-5 py-2.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"
>
{t('filters.upgradeToFullMap')}
</button>
<svg
viewBox="0 120 1600 230"
className="w-full mt-4 block shrink-0"
preserveAspectRatio="xMidYMax meet"
>
<path
d="M0,350 C400,150 1200,150 1600,350 Z"
className="fill-green-500 dark:fill-green-600"
/>
<path d="M100,350 C450,180 1150,180 1500,350 Z" fill="#000" opacity="0.08" />
<path d="M250,350 C550,220 1050,220 1350,350 Z" fill="#000" opacity="0.06" />
<image href="/house.png" x="735" y="110" width="130" height="120" />
</svg>
</div>
)}
</div>
</div>
)}
{!isLicensed && (
<div className="mt-auto shrink-0 flex flex-col items-center px-5 pt-4 pb-0 border-t border-warm-200 dark:border-warm-700">
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-relaxed mb-1">
{t('filters.upgradePrompt')}
</p>
<p className="text-xs text-warm-400 dark:text-warm-500 text-center mb-4">
{t('filters.oneTimeLifetime')}
</p>
<button
onClick={onUpgradeClick}
className="px-5 py-2.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"
>
{t('filters.upgradeToFullMap')}
</button>
<svg
viewBox="0 120 1600 230"
className="w-full mt-4 block shrink-0"
preserveAspectRatio="xMidYMax meet"
>
<path
d="M0,350 C400,150 1200,150 1600,350 Z"
className="fill-green-500 dark:fill-green-600"
/>
<path d="M100,350 C450,180 1150,180 1500,350 Z" fill="#000" opacity="0.08" />
<path d="M250,350 C550,220 1050,220 1350,350 Z" fill="#000" opacity="0.06" />
<image href="/house.png" x="735" y="110" width="130" height="120" />
</svg>
</div>
)}
</div>
)}
</div>

View file

@ -1,5 +1,7 @@
import { useRef, useCallback, useEffect, useId, type ReactNode } from 'react';
import { useCallback, useEffect, useId, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useClickOutside } from '../../hooks/useClickOutside';
import { useModalA11y } from '../../hooks/useModalA11y';
import { CloseIcon } from './icons';
import { IconButton } from './IconButton';
@ -11,8 +13,7 @@ interface InfoPopupProps {
}
export default function InfoPopup({ title, children, onClose, sourceLink }: InfoPopupProps) {
const popupRef = useRef<HTMLDivElement>(null);
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
const popupRef = useModalA11y();
const titleId = useId();
const handleClose = useCallback(() => {
@ -29,20 +30,9 @@ export default function InfoPopup({ title, children, onClose, sourceLink }: Info
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
useEffect(() => {
previouslyFocusedRef.current = document.activeElement as HTMLElement | null;
const firstFocusable = popupRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
(firstFocusable ?? popupRef.current)?.focus();
return () => {
previouslyFocusedRef.current?.focus?.();
};
}, []);
return (
const popup = (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4"
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 p-4 dark:bg-black/70"
role="presentation"
>
<div
@ -73,4 +63,8 @@ export default function InfoPopup({ title, children, onClose, sourceLink }: Info
</div>
</div>
);
if (typeof document === 'undefined') return popup;
return createPortal(popup, document.body);
}

View file

@ -160,7 +160,7 @@ export default function MobileMenu({
<div className="fixed inset-0 bg-black/50 z-[70]" onClick={onClose} />
{/* Menu panel */}
<div className="mobile-menu-panel fixed top-0 right-0 bottom-0 w-64 bg-navy-900 text-white z-[80] flex flex-col shadow-xl">
<div className="flex items-center justify-between px-3 h-11 border-b border-navy-700">
<div className="flex items-center justify-between px-3 h-12 border-b border-navy-700">
<span className="font-semibold">{t('mobileMenu.menu')}</span>
<button
onClick={onClose}