More FE changes

This commit is contained in:
Andras Schmelczer 2026-05-09 09:43:41 +01:00
parent f114ada255
commit a48eb945e0
48 changed files with 4127 additions and 1751 deletions

View file

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="138" height="159" viewBox="0 0 138 159">
<g fill="none" stroke="#2dd4bf" stroke-width="1.2" stroke-linejoin="round" stroke-opacity="0.58">
<path d="M46 0L69 40L46 80H0L-23 40L0 0Z"/>
<path d="M46 80L69 120L46 159H0L-23 120L0 80Z"/>
<path d="M115 40L138 80L115 120H69L46 80L69 40Z"/>
<path d="M184 0L207 40L184 80H138L115 40L138 0Z"/>
<path d="M184 80L207 120L184 159H138L115 120L138 80Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 473 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 355 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Before After
Before After

Binary file not shown.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,7 @@ export default function EnumBarChart({
}: { }: {
counts: Record<string, number>; counts: Record<string, number>;
globalCounts?: Record<string, number>; globalCounts?: Record<string, number>;
featureName?: string; featureName: string;
}) { }) {
const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA); const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA);
const localTotal = entries.reduce((sum, [, c]) => sum + c, 0); const localTotal = entries.reduce((sum, [, c]) => sum + c, 0);
@ -40,10 +40,8 @@ export default function EnumBarChart({
: (count / maxCount) * 100; : (count / maxCount) * 100;
const globalWidth = hasGlobal && maxPct > 0 ? (globalPct / maxPct) * 100 : 0; const globalWidth = hasGlobal && maxPct > 0 ? (globalPct / maxPct) * 100 : 0;
const overrideColor = featureName ? getEnumValueColor(featureName, label) : null; const color = getEnumValueColor(featureName, label);
const barStyle = overrideColor const barStyle = `rgb(${color[0]},${color[1]},${color[2]})`;
? `rgb(${overrideColor[0]},${overrideColor[1]},${overrideColor[2]})`
: undefined;
return ( return (
<div key={label} className="flex items-center gap-2 text-xs"> <div key={label} className="flex items-center gap-2 text-xs">
@ -58,14 +56,10 @@ export default function EnumBarChart({
/> />
)} )}
<div <div
className={ className="h-full rounded relative"
barStyle
? 'h-full rounded relative'
: 'h-full bg-teal-500 dark:bg-teal-400 rounded relative'
}
style={{ style={{
width: `${localWidth}%`, width: `${localWidth}%`,
...(barStyle ? { backgroundColor: barStyle } : {}), backgroundColor: barStyle,
}} }}
/> />
</div> </div>

View file

@ -6,7 +6,7 @@ import { SearchInput } from '../ui/SearchInput';
import { FilterIcon } from '../ui/icons'; import { FilterIcon } from '../ui/icons';
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader'; import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
import { EmptyState } from '../ui/EmptyState'; import { EmptyState } from '../ui/EmptyState';
import type { FeatureMeta } from '../../types'; import type { FeatureGroup, FeatureMeta } from '../../types';
import { groupFeaturesByCategory } from '../../lib/features'; import { groupFeaturesByCategory } from '../../lib/features';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup'; import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { FeatureActions } from '../ui/FeatureIcons'; import { FeatureActions } from '../ui/FeatureIcons';
@ -35,6 +35,16 @@ interface FeatureBrowserProps {
onAddTravelTimeEntry: (mode: TransportMode) => void; onAddTravelTimeEntry: (mode: TransportMode) => void;
} }
function moveTransportFirst(groups: FeatureGroup[]): FeatureGroup[] {
const transportIdx = groups.findIndex((group) => group.name === 'Transport');
if (transportIdx <= 0) return groups;
return [
groups[transportIdx],
...groups.slice(0, transportIdx),
...groups.slice(transportIdx + 1),
];
}
export default function FeatureBrowser({ export default function FeatureBrowser({
availableFeatures, availableFeatures,
allFeatures, allFeatures,
@ -73,7 +83,7 @@ export default function FeatureBrowser({
); );
}, [availableFeatures, search]); }, [availableFeatures, search]);
const grouped = useMemo(() => groupFeaturesByCategory(filtered), [filtered]); const grouped = useMemo(() => moveTransportFirst(groupFeaturesByCategory(filtered)), [filtered]);
// When searching, expand all groups so results are visible // When searching, expand all groups so results are visible
const isSearching = search.length > 0; const isSearching = search.length > 0;
@ -91,14 +101,11 @@ export default function FeatureBrowser({
search.toLowerCase() search.toLowerCase()
)); ));
// Ensure "Transport" group exists when travel modes should be shown // Ensure "Transport" group exists first when travel modes should be shown.
const mergedGrouped = useMemo(() => { const mergedGrouped = useMemo(() => {
if (!showTravelModes) return grouped; if (!showTravelModes) return grouped;
if (grouped.some((g) => g.name === 'Transport')) return grouped; if (grouped.some((g) => g.name === 'Transport')) return grouped;
const groups = [...grouped]; return [{ name: 'Transport', features: [] }, ...grouped];
const propsIdx = groups.findIndex((g) => g.name === 'Properties in the area');
groups.splice(propsIdx === -1 ? 0 : propsIdx + 1, 0, { name: 'Transport', features: [] });
return groups;
}, [grouped, showTravelModes]); }, [grouped, showTravelModes]);
return ( return (
@ -133,26 +140,6 @@ export default function FeatureBrowser({
</CollapsibleGroupHeader> </CollapsibleGroupHeader>
{isExpanded && ( {isExpanded && (
<> <>
{group.features.map((f) => {
const isPinned = pinnedFeature === f.name;
return (
<div
key={f.name}
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
>
<div className="min-w-0 mr-2">
<FeatureLabel feature={f} size="sm" description={f.description} />
</div>
<FeatureActions
feature={f}
isPinned={isPinned}
onTogglePin={onTogglePin}
onShowInfo={setInfoFeature}
onAdd={onAddFilter}
/>
</div>
);
})}
{group.name === 'Transport' && {group.name === 'Transport' &&
showTravelModes && showTravelModes &&
visibleModes.map((mode) => { visibleModes.map((mode) => {
@ -179,22 +166,46 @@ export default function FeatureBrowser({
<div className="flex items-center gap-0.5 shrink-0"> <div className="flex items-center gap-0.5 shrink-0">
<IconButton <IconButton
onClick={() => setTravelInfoMode(mode)} onClick={() => setTravelInfoMode(mode)}
title={t('filters.featureInfo')} title={t('filters.aboutData')}
size="md" size="md"
> >
<InfoIcon className="w-5 h-5 md:w-3.5 md:h-3.5" /> <InfoIcon className="w-5 h-5 md:w-3.5 md:h-3.5" />
</IconButton> </IconButton>
<button <button
type="button"
onClick={() => onAddTravelTimeEntry(mode)} onClick={() => onAddTravelTimeEntry(mode)}
title={t('travel.addTravelTime', { mode: modes.label(mode) })} title={t('travel.addTravelTime', { mode: modes.label(mode) })}
className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40" aria-label={t('travel.addTravelTime', { mode: modes.label(mode) })}
className="inline-flex items-center gap-1 rounded-md bg-teal-50 px-2 py-1 text-xs font-semibold text-teal-700 hover:bg-teal-100 dark:bg-teal-900/30 dark:text-teal-300 dark:hover:bg-teal-800/40"
> >
<PlusIcon className="w-5 h-5 md:w-5 md:h-5" strokeWidth={2.5} /> <PlusIcon className="w-4 h-4" strokeWidth={2.5} />
<span>{t('filters.addFilterAction')}</span>
</button> </button>
</div> </div>
</div> </div>
); );
})} })}
{group.features.map((f) => {
const isPinned = pinnedFeature === f.name;
return (
<div
key={f.name}
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
>
<div className="min-w-0 mr-2">
<FeatureLabel feature={f} size="sm" description={f.description} />
</div>
<FeatureActions
feature={f}
isPinned={isPinned}
onTogglePin={onTogglePin}
onShowInfo={setInfoFeature}
onAdd={onAddFilter}
showText
/>
</div>
);
})}
</> </>
)} )}
</div> </div>

View file

@ -40,6 +40,17 @@ import {
isSpecificCrimeFilterName, isSpecificCrimeFilterName,
replaceSpecificCrimeFilterKeySelection, replaceSpecificCrimeFilterKeySelection,
} from '../../lib/crime-filter'; } from '../../lib/crime-filter';
import {
ETHNICITIES_FILTER_NAME,
ETHNICITY_FEATURE_NAMES,
clampEthnicityRange,
getDefaultEthnicityFeatureName,
getEthnicityFeatureName,
getEthnicityFilterMeta,
isEthnicityFeatureName,
isEthnicityFilterName,
replaceEthnicityFilterKeySelection,
} from '../../lib/ethnicity-filter';
import { import {
SCHOOL_FILTER_NAME, SCHOOL_FILTER_NAME,
clampSchoolRange, clampSchoolRange,
@ -53,6 +64,25 @@ import {
type SchoolPhase, type SchoolPhase,
type SchoolRating, type SchoolRating,
} from '../../lib/school-filter'; } from '../../lib/school-filter';
import {
POI_FILTER_NAMES,
POI_DISTANCE_FILTER_NAME,
POI_COUNT_2KM_FILTER_NAME,
POI_COUNT_5KM_FILTER_NAME,
clampPoiFilterRange,
getDefaultPoiDistanceFeatureName,
getDefaultPoiFilterFeatureName,
getPoiFeatureCategory,
getPoiDistanceFeatureName,
getPoiFilterFeatureOptions,
getPoiFilterMeta,
getPoiDistanceFilterMeta,
getPoiFilterName,
isPoiDistanceFilterName,
isPoiFilterFeatureName,
replacePoiFilterKeySelection,
type PoiFilterName,
} from '../../lib/poi-distance-filter';
function EditableLabel({ function EditableLabel({
value, value,
@ -146,9 +176,10 @@ function SliderLabels({
const leftPct = Math.max(0, Math.min(100, ((value[0] - min) / range) * 100)); const leftPct = Math.max(0, Math.min(100, ((value[0] - min) / range) * 100));
const rightPct = Math.max(0, Math.min(100, ((value[1] - min) / range) * 100)); const rightPct = Math.max(0, Math.min(100, ((value[1] - min) / range) * 100));
const labels = displayValues || value; const labels = displayValues || value;
const labelFormat = feature?.suffix === '%' ? { raw, suffix: feature.suffix } : raw;
const minLabel = isAtMin ? 'min' : formatFilterValue(labels[0], raw); const minLabel = isAtMin ? 'min' : formatFilterValue(labels[0], labelFormat);
const maxLabel = isAtMax ? 'max' : formatFilterValue(labels[1], raw); const maxLabel = isAtMax ? 'max' : formatFilterValue(labels[1], labelFormat);
// Smoothly spread labels apart as thumbs get close to prevent overlap. // Smoothly spread labels apart as thumbs get close to prevent overlap.
// t=1 (centered) when far apart, t=0 (split) when touching. // t=1 (centered) when far apart, t=0 (split) when touching.
@ -614,6 +645,409 @@ function SpecificCrimeFilterCard({
); );
} }
function EthnicityFilterCard({
features,
ethnicityFeature,
filters,
activeFeature,
dragValue,
pinnedFeature,
filterImpact,
percentileScale,
onFilterChange,
onDragStart,
onDragChange,
onDragEnd,
onTogglePin,
onShowInfo,
onRemove,
}: {
features: FeatureMeta[];
ethnicityFeature: FeatureMeta;
filters: FeatureFilters;
activeFeature: string | null;
dragValue: [number, number] | null;
pinnedFeature: string | null;
filterImpact?: number;
percentileScale?: PercentileScale;
onFilterChange: (name: string, value: [number, number] | string[]) => void;
onDragStart: (name: string) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onTogglePin: (name: string) => void;
onShowInfo: (feature: FeatureMeta) => void;
onRemove: () => void;
}) {
const ethnicityMeta = getEthnicityFilterMeta(features);
const ethnicityOptions = ETHNICITY_FEATURE_NAMES.map((name) =>
features.find((feature) => feature.name === name)
).filter((feature): feature is FeatureMeta => Boolean(feature));
const selectedFeatureName =
getEthnicityFeatureName(ethnicityFeature.name) ?? getDefaultEthnicityFeatureName(features);
const selectedFeature = selectedFeatureName
? features.find((feature) => feature.name === selectedFeatureName)
: undefined;
if (!selectedFeature || ethnicityOptions.length === 0 || !selectedFeatureName) return null;
const isActive = activeFeature === ethnicityFeature.name;
const isPinned = pinnedFeature === ethnicityFeature.name;
const hist = selectedFeature.histogram;
const dataMin = hist?.min ?? selectedFeature.min ?? 0;
const dataMax = hist?.max ?? selectedFeature.max ?? 100;
const displayValue =
isActive && dragValue
? dragValue
: (filters[ethnicityFeature.name] as [number, number]) || [dataMin, dataMax];
const scale = percentileScale;
const clampMin = displayValue[0] <= dataMin;
const clampMax = displayValue[1] >= dataMax;
const isAtMin = displayValue[0] === dataMin;
const isAtMax = displayValue[1] === dataMax;
const sliderValue: [number, number] = scale
? [
clampMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
clampMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
]
: [
clampMin ? (selectedFeature.min ?? dataMin) : displayValue[0],
clampMax ? (selectedFeature.max ?? dataMax) : displayValue[1],
];
const replaceEthnicityFeature = (nextFeatureName: string) => {
const nextName = replaceEthnicityFilterKeySelection(ethnicityFeature.name, nextFeatureName);
if (nextName === ethnicityFeature.name) return;
const nextFeature = features.find((feature) => feature.name === nextFeatureName);
const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0;
const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax);
const nextRange = clampEthnicityRange(
[
displayValue[0] <= dataMin ? nextDataMin : displayValue[0],
displayValue[1] >= dataMax ? nextDataMax : displayValue[1],
],
nextFeature
);
onFilterChange(nextName, nextRange);
if (isPinned) onTogglePin(nextName);
};
const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0';
const mobileIcon =
getFeatureIcon(selectedFeature.name, mobileIconClass) ||
(() => {
const G = selectedFeature.group ? getGroupIcon(selectedFeature.group) : null;
return G ? <G className={mobileIconClass} /> : null;
})();
return (
<div
data-filter-name={ETHNICITIES_FILTER_NAME}
className={`space-y-2 rounded-lg border border-warm-200 bg-white px-2 py-2 shadow-sm dark:border-warm-700 dark:bg-warm-800 ${
isActive
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
: isPinned
? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20'
: ''
}`}
>
<div className="relative z-10 flex items-center justify-between gap-1">
<FeatureLabel
feature={ethnicityMeta}
size="sm"
className="min-w-0 shrink"
hideIconOnMobile
/>
<FeatureActions
feature={selectedFeature}
actionName={ethnicityFeature.name}
isPinned={isPinned}
isPreviewing={isActive}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={onRemove}
/>
</div>
<div>
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
Ethnicity
</label>
<div className="relative">
<select
value={selectedFeatureName}
onChange={(e) => replaceEthnicityFeature(e.target.value)}
className="w-full appearance-none rounded-md border border-warm-200 bg-warm-50 px-2 py-1.5 pr-8 text-sm font-medium text-navy-950 shadow-inner outline-none transition-colors hover:bg-white focus:border-teal-400 focus:ring-2 focus:ring-teal-200 dark:border-warm-700 dark:bg-navy-900 dark:text-warm-100 dark:hover:bg-navy-800 dark:focus:ring-teal-900/50"
>
{ethnicityOptions.map((option) => (
<option key={option.name} value={option.name}>
{ts(option.name)}
</option>
))}
</select>
<ChevronIcon
direction="down"
className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-warm-400 dark:text-warm-500"
/>
</div>
</div>
<div className="flex items-start gap-1.5 md:block">
{mobileIcon && <div className="shrink-0 pt-0.5 md:hidden">{mobileIcon}</div>}
<div className="min-w-0 flex-1">
<Slider
min={scale ? 0 : (selectedFeature.min ?? dataMin)}
max={scale ? 100 : (selectedFeature.max ?? dataMax)}
step={
scale
? 1
: (selectedFeature.step ??
((selectedFeature.max ?? dataMax) - (selectedFeature.min ?? dataMin)) / 100)
}
value={sliderValue}
onValueChange={
scale
? ([pMin, pMax]) => {
const step = selectedFeature.step ?? 1;
const snap = (v: number) => Math.round(v / step) * step;
onDragChange([
pMin <= 0 ? dataMin : snap(scale.toValue(pMin)),
pMax >= 100 ? dataMax : snap(scale.toValue(pMax)),
]);
}
: ([min, max]) =>
onDragChange([
min <= (selectedFeature.min ?? dataMin) ? dataMin : min,
max >= (selectedFeature.max ?? dataMax) ? dataMax : max,
])
}
onPointerDown={() => onDragStart(ethnicityFeature.name)}
onPointerUp={() => onDragEnd()}
/>
<SliderLabels
min={scale ? 0 : (selectedFeature.min ?? dataMin)}
max={scale ? 100 : (selectedFeature.max ?? dataMax)}
value={sliderValue}
displayValues={displayValue}
isAtMin={isAtMin}
isAtMax={isAtMax}
raw={selectedFeature.raw}
feature={selectedFeature}
onValueChange={(v) =>
onFilterChange(ethnicityFeature.name, clampEthnicityRange(v, selectedFeature))
}
/>
{filterImpact != null && filterImpact > 0 && (
<p className="-mt-1 ml-2.5 text-[10px] text-warm-400 dark:text-warm-500">
+{formatNumber(filterImpact)} without this filter
</p>
)}
</div>
</div>
</div>
);
}
function PoiDistanceFilterCard({
features,
poiFeature,
filters,
activeFeature,
dragValue,
pinnedFeature,
filterImpact,
percentileScale,
onFilterChange,
onDragStart,
onDragChange,
onDragEnd,
onTogglePin,
onShowInfo,
onRemove,
}: {
features: FeatureMeta[];
poiFeature: FeatureMeta;
filters: FeatureFilters;
activeFeature: string | null;
dragValue: [number, number] | null;
pinnedFeature: string | null;
filterImpact?: number;
percentileScale?: PercentileScale;
onFilterChange: (name: string, value: [number, number] | string[]) => void;
onDragStart: (name: string) => void;
onDragChange: (value: [number, number]) => void;
onDragEnd: () => void;
onTogglePin: (name: string) => void;
onShowInfo: (feature: FeatureMeta) => void;
onRemove: () => void;
}) {
const filterName = getPoiFilterName(poiFeature.name) ?? POI_DISTANCE_FILTER_NAME;
const poiMeta = getPoiFilterMeta(features, filterName);
const poiOptions = getPoiFilterFeatureOptions(features, filterName);
const selectedFeatureName =
getPoiDistanceFeatureName(poiFeature.name) ??
getDefaultPoiFilterFeatureName(features, filterName);
const selectedFeature = selectedFeatureName
? features.find((feature) => feature.name === selectedFeatureName)
: undefined;
if (!selectedFeature || poiOptions.length === 0 || !selectedFeatureName) return null;
const isActive = activeFeature === poiFeature.name;
const isPinned = pinnedFeature === poiFeature.name;
const hist = selectedFeature.histogram;
const dataMin = hist?.min ?? selectedFeature.min ?? 0;
const dataMax = hist?.max ?? selectedFeature.max ?? 5;
const displayValue =
isActive && dragValue
? dragValue
: (filters[poiFeature.name] as [number, number]) || [dataMin, dataMax];
const scale = percentileScale;
const clampMin = displayValue[0] <= dataMin;
const clampMax = displayValue[1] >= dataMax;
const isAtMin = displayValue[0] === dataMin;
const isAtMax = displayValue[1] === dataMax;
const sliderValue: [number, number] = scale
? [
clampMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
clampMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
]
: [
clampMin ? (selectedFeature.min ?? dataMin) : displayValue[0],
clampMax ? (selectedFeature.max ?? dataMax) : displayValue[1],
];
const replacePoiFeature = (nextFeatureName: string) => {
const nextName = replacePoiFilterKeySelection(poiFeature.name, nextFeatureName);
if (nextName === poiFeature.name) return;
const nextFeature = features.find((feature) => feature.name === nextFeatureName);
const nextDataMin = nextFeature?.histogram?.min ?? nextFeature?.min ?? 0;
const nextDataMax = nextFeature?.histogram?.max ?? nextFeature?.max ?? Math.max(1, dataMax);
const nextRange = clampPoiFilterRange(
[
displayValue[0] <= dataMin ? nextDataMin : displayValue[0],
displayValue[1] >= dataMax ? nextDataMax : displayValue[1],
],
nextFeature
);
onFilterChange(nextName, nextRange);
if (isPinned) onTogglePin(nextName);
};
const mobileIconClass = 'w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0';
const mobileIcon =
getFeatureIcon(selectedFeature.name, mobileIconClass) ||
(() => {
const G = selectedFeature.group ? getGroupIcon(selectedFeature.group) : null;
return G ? <G className={mobileIconClass} /> : null;
})();
return (
<div
data-filter-name={filterName}
className={`space-y-2 rounded-lg border border-warm-200 bg-white px-2 py-2 shadow-sm dark:border-warm-700 dark:bg-warm-800 ${
isActive
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
: isPinned
? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20'
: ''
}`}
>
<div className="relative z-10 flex items-center justify-between gap-1">
<FeatureLabel feature={poiMeta} size="sm" className="min-w-0 shrink" hideIconOnMobile />
<FeatureActions
feature={selectedFeature}
actionName={poiFeature.name}
isPinned={isPinned}
isPreviewing={isActive}
onTogglePin={onTogglePin}
onShowInfo={onShowInfo}
onRemove={onRemove}
/>
</div>
<div>
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
POI type
</label>
<div className="relative">
<select
value={selectedFeatureName}
onChange={(e) => replacePoiFeature(e.target.value)}
className="w-full appearance-none rounded-md border border-warm-200 bg-warm-50 px-2 py-1.5 pr-8 text-sm font-medium text-navy-950 shadow-inner outline-none transition-colors hover:bg-white focus:border-teal-400 focus:ring-2 focus:ring-teal-200 dark:border-warm-700 dark:bg-navy-900 dark:text-warm-100 dark:hover:bg-navy-800 dark:focus:ring-teal-900/50"
>
{poiOptions.map((option) => (
<option key={option.name} value={option.name}>
{ts(getPoiFeatureCategory(option.name) ?? option.name)}
</option>
))}
</select>
<ChevronIcon
direction="down"
className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-warm-400 dark:text-warm-500"
/>
</div>
</div>
<div className="flex items-start gap-1.5 md:block">
{mobileIcon && <div className="shrink-0 pt-0.5 md:hidden">{mobileIcon}</div>}
<div className="min-w-0 flex-1">
<Slider
min={scale ? 0 : (selectedFeature.min ?? dataMin)}
max={scale ? 100 : (selectedFeature.max ?? dataMax)}
step={
scale
? 1
: (selectedFeature.step ??
((selectedFeature.max ?? dataMax) - (selectedFeature.min ?? dataMin)) / 100)
}
value={sliderValue}
onValueChange={
scale
? ([pMin, pMax]) => {
const step = selectedFeature.step ?? 0.1;
const snap = (v: number) => Math.round(v / step) * step;
onDragChange([
pMin <= 0 ? dataMin : snap(scale.toValue(pMin)),
pMax >= 100 ? dataMax : snap(scale.toValue(pMax)),
]);
}
: ([min, max]) =>
onDragChange([
min <= (selectedFeature.min ?? dataMin) ? dataMin : min,
max >= (selectedFeature.max ?? dataMax) ? dataMax : max,
])
}
onPointerDown={() => onDragStart(poiFeature.name)}
onPointerUp={() => onDragEnd()}
/>
<SliderLabels
min={scale ? 0 : (selectedFeature.min ?? dataMin)}
max={scale ? 100 : (selectedFeature.max ?? dataMax)}
value={sliderValue}
displayValues={displayValue}
isAtMin={isAtMin}
isAtMax={isAtMax}
raw={selectedFeature.raw}
feature={selectedFeature}
onValueChange={(v) =>
onFilterChange(poiFeature.name, clampPoiFilterRange(v, selectedFeature))
}
/>
{filterImpact != null && filterImpact > 0 && (
<p className="-mt-1 ml-2.5 text-[10px] text-warm-400 dark:text-warm-500">
+{formatNumber(filterImpact)} without this filter
</p>
)}
</div>
</div>
</div>
);
}
interface FiltersProps { interface FiltersProps {
features: FeatureMeta[]; features: FeatureMeta[];
filters: FeatureFilters; filters: FeatureFilters;
@ -712,6 +1146,48 @@ export default memo(function Filters({
[features] [features]
); );
const specificCrimeMeta = useMemo(() => getSpecificCrimeFilterMeta(features), [features]); const specificCrimeMeta = useMemo(() => getSpecificCrimeFilterMeta(features), [features]);
const defaultEthnicityFeatureName = useMemo(
() => getDefaultEthnicityFeatureName(features),
[features]
);
const ethnicityMeta = useMemo(() => getEthnicityFilterMeta(features), [features]);
const defaultPoiDistanceFeatureName = useMemo(
() => getDefaultPoiDistanceFeatureName(features),
[features]
);
const defaultPoiCount2KmFeatureName = useMemo(
() => getDefaultPoiFilterFeatureName(features, POI_COUNT_2KM_FILTER_NAME),
[features]
);
const defaultPoiCount5KmFeatureName = useMemo(
() => getDefaultPoiFilterFeatureName(features, POI_COUNT_5KM_FILTER_NAME),
[features]
);
const poiDistanceMeta = useMemo(() => getPoiDistanceFilterMeta(features), [features]);
const poiCount2KmMeta = useMemo(
() => getPoiFilterMeta(features, POI_COUNT_2KM_FILTER_NAME),
[features]
);
const poiCount5KmMeta = useMemo(
() => getPoiFilterMeta(features, POI_COUNT_5KM_FILTER_NAME),
[features]
);
const poiFilterMetas = useMemo(
() => ({
[POI_DISTANCE_FILTER_NAME]: poiDistanceMeta,
[POI_COUNT_2KM_FILTER_NAME]: poiCount2KmMeta,
[POI_COUNT_5KM_FILTER_NAME]: poiCount5KmMeta,
}),
[poiDistanceMeta, poiCount2KmMeta, poiCount5KmMeta]
);
const defaultPoiFilterFeatureNames = useMemo(
() => ({
[POI_DISTANCE_FILTER_NAME]: defaultPoiDistanceFeatureName,
[POI_COUNT_2KM_FILTER_NAME]: defaultPoiCount2KmFeatureName,
[POI_COUNT_5KM_FILTER_NAME]: defaultPoiCount5KmFeatureName,
}),
[defaultPoiDistanceFeatureName, defaultPoiCount2KmFeatureName, defaultPoiCount5KmFeatureName]
);
const schoolFilterItems = useMemo(() => { const schoolFilterItems = useMemo(() => {
return Object.keys(filters) return Object.keys(filters)
.filter(isSchoolFilterName) .filter(isSchoolFilterName)
@ -734,10 +1210,35 @@ export default memo(function Filters({
return { ...(backendFeature ?? specificCrimeMeta), name, group: 'Crime' }; return { ...(backendFeature ?? specificCrimeMeta), name, group: 'Crime' };
}); });
}, [filters, features, specificCrimeMeta]); }, [filters, features, specificCrimeMeta]);
const ethnicityFilterItems = useMemo(() => {
return Object.keys(filters)
.filter(isEthnicityFilterName)
.map((name) => {
const backendName = getEthnicityFeatureName(name);
const backendFeature = backendName
? features.find((feature) => feature.name === backendName)
: undefined;
return { ...(backendFeature ?? ethnicityMeta), name, group: 'Demographics' };
});
}, [filters, features, ethnicityMeta]);
const poiDistanceFilterItems = useMemo(() => {
return Object.keys(filters)
.filter(isPoiDistanceFilterName)
.map((name) => {
const backendName = getPoiDistanceFeatureName(name);
const filterName = getPoiFilterName(name) ?? POI_DISTANCE_FILTER_NAME;
const backendFeature = backendName
? features.find((feature) => feature.name === backendName)
: undefined;
return { ...(backendFeature ?? poiFilterMetas[filterName]), name, group: 'Nearby POIs' };
});
}, [filters, features, poiFilterMetas]);
const availableFeatures = useMemo(() => { const availableFeatures = useMemo(() => {
const result: FeatureMeta[] = []; const result: FeatureMeta[] = [];
let insertedSchoolFilter = false; let insertedSchoolFilter = false;
let insertedSpecificCrimeFilter = false; let insertedSpecificCrimeFilter = false;
let insertedEthnicityFilter = false;
const insertedPoiFilters = new Set<PoiFilterName>();
for (const feature of features) { for (const feature of features) {
if (isSchoolFilterName(feature.name)) { if (isSchoolFilterName(feature.name)) {
@ -754,6 +1255,25 @@ export default memo(function Filters({
} }
continue; continue;
} }
if (isEthnicityFeatureName(feature.name)) {
if (defaultEthnicityFeatureName && !insertedEthnicityFilter) {
result.push(ethnicityMeta);
insertedEthnicityFilter = true;
}
continue;
}
if (isPoiFilterFeatureName(feature.name)) {
const filterName = getPoiFilterName(feature.name);
if (
filterName &&
defaultPoiFilterFeatureNames[filterName] &&
!insertedPoiFilters.has(filterName)
) {
result.push(poiFilterMetas[filterName]);
insertedPoiFilters.add(filterName);
}
continue;
}
if (!enabledFeatures.has(feature.name)) result.push(feature); if (!enabledFeatures.has(feature.name)) result.push(feature);
} }
@ -765,11 +1285,17 @@ export default memo(function Filters({
schoolMeta, schoolMeta,
defaultSpecificCrimeFeatureName, defaultSpecificCrimeFeatureName,
specificCrimeMeta, specificCrimeMeta,
defaultEthnicityFeatureName,
ethnicityMeta,
defaultPoiFilterFeatureNames,
poiFilterMetas,
]); ]);
const enabledFeatureList = useMemo(() => { const enabledFeatureList = useMemo(() => {
const result: FeatureMeta[] = []; const result: FeatureMeta[] = [];
let insertedSchoolFilter = false; let insertedSchoolFilter = false;
let insertedSpecificCrimeFilters = false; let insertedSpecificCrimeFilters = false;
let insertedEthnicityFilters = false;
let insertedPoiDistanceFilters = false;
for (const feature of features) { for (const feature of features) {
if (isSchoolFilterName(feature.name)) { if (isSchoolFilterName(feature.name)) {
@ -786,11 +1312,32 @@ export default memo(function Filters({
} }
continue; continue;
} }
if (isEthnicityFeatureName(feature.name)) {
if (!insertedEthnicityFilters) {
result.push(...ethnicityFilterItems);
insertedEthnicityFilters = true;
}
continue;
}
if (isPoiFilterFeatureName(feature.name)) {
if (!insertedPoiDistanceFilters) {
result.push(...poiDistanceFilterItems);
insertedPoiDistanceFilters = true;
}
continue;
}
if (enabledFeatures.has(feature.name)) result.push(feature); if (enabledFeatures.has(feature.name)) result.push(feature);
} }
return result; return result;
}, [features, enabledFeatures, schoolFilterItems, specificCrimeFilterItems]); }, [
features,
enabledFeatures,
schoolFilterItems,
specificCrimeFilterItems,
ethnicityFilterItems,
poiDistanceFilterItems,
]);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
@ -816,11 +1363,30 @@ export default memo(function Filters({
onAddFilter(SPECIFIC_CRIMES_FILTER_NAME); onAddFilter(SPECIFIC_CRIMES_FILTER_NAME);
return; return;
} }
if (name === ETHNICITIES_FILTER_NAME) {
if (!defaultEthnicityFeatureName) return;
pendingScrollRef.current = ETHNICITIES_FILTER_NAME;
onAddFilter(ETHNICITIES_FILTER_NAME);
return;
}
if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
const filterName = name as PoiFilterName;
if (!defaultPoiFilterFeatureNames[filterName]) return;
pendingScrollRef.current = filterName;
onAddFilter(filterName);
return;
}
pendingScrollRef.current = name; pendingScrollRef.current = name;
onAddFilter(name); onAddFilter(name);
}, },
[defaultSchoolFeatureName, defaultSpecificCrimeFeatureName, onAddFilter] [
defaultSchoolFeatureName,
defaultSpecificCrimeFeatureName,
defaultEthnicityFeatureName,
defaultPoiFilterFeatureNames,
onAddFilter,
]
); );
const handleRemoveSchoolFilter = useCallback( const handleRemoveSchoolFilter = useCallback(
@ -857,15 +1423,8 @@ export default memo(function Filters({
return scales; return scales;
}, [features]); }, [features]);
// Insert travel time cards right before the first Transport feature, // Keep commute controls at the top of active filters, before other Transport filters.
// so they visually group with their category. const travelInsertIdx = 0;
const travelInsertIdx = useMemo(() => {
const idx = enabledFeatureList.findIndex((f) => f.group === 'Transport');
if (idx >= 0) return idx;
// No transport features enabled — place after Properties, before next group
const afterProps = enabledFeatureList.findIndex((f) => f.group !== 'Properties');
return afterProps >= 0 ? afterProps : enabledFeatureList.length;
}, [enabledFeatureList]);
const badgeCount = enabledFeatureList.length + activeEntryCount; const badgeCount = enabledFeatureList.length + activeEntryCount;
@ -920,11 +1479,7 @@ export default memo(function Filters({
> >
<div <div
className={`flex flex-col md:min-h-0 ${ className={`flex flex-col md:min-h-0 ${
activeFilterCollapsed activeFilterCollapsed ? 'md:[flex:0_0_auto]' : 'md:[flex:0_1_auto]'
? 'md:[flex:0_0_auto]'
: addFilterCollapsed
? 'md:[flex:1_1_0]'
: 'md:[flex:3_1_0]'
}`} }`}
> >
<button <button
@ -969,10 +1524,7 @@ export default memo(function Filters({
</button> </button>
{!activeFilterCollapsed && ( {!activeFilterCollapsed && (
<div <div ref={scrollRef} className="md:min-h-0 md:overflow-y-auto overflow-x-hidden">
ref={scrollRef}
className="md:flex-1 md:min-h-0 md:overflow-y-auto overflow-x-hidden"
>
<AiFilterInput <AiFilterInput
loading={aiFilterLoading} loading={aiFilterLoading}
error={aiFilterError} error={aiFilterError}
@ -1113,6 +1665,118 @@ export default memo(function Filters({
); );
} }
if (isEthnicityFilterName(feature.name)) {
const ethnicityBackendName = getEthnicityFeatureName(feature.name);
return (
<Fragment key={feature.name}>
{featureIdx === travelInsertIdx &&
travelTimeEntries.map((entry, index) => (
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
isActive={activeFeature === travelFieldKey(entry)}
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label, lat, lon) =>
onTravelTimeSetDestination(index, slug, label, lat, lon)
}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(travelFieldKey(entry))}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
destinationDropdownPortal={destinationDropdownPortal}
/>
</div>
))}
<EthnicityFilterCard
features={features}
ethnicityFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={
ethnicityBackendName ? filterImpacts?.[ethnicityBackendName] : undefined
}
percentileScale={
ethnicityBackendName
? percentileScales.get(ethnicityBackendName)
: undefined
}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={setActiveInfoFeature}
onRemove={() => onRemoveFilter(feature.name)}
/>
</Fragment>
);
}
if (isPoiDistanceFilterName(feature.name)) {
const poiBackendName = getPoiDistanceFeatureName(feature.name);
return (
<Fragment key={feature.name}>
{featureIdx === travelInsertIdx &&
travelTimeEntries.map((entry, index) => (
<div key={`tt_${index}`} data-filter-name={`tt_${index}`}>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
isActive={activeFeature === travelFieldKey(entry)}
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label, lat, lon) =>
onTravelTimeSetDestination(index, slug, label, lat, lon)
}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onDragStart={() => onDragStart(travelFieldKey(entry))}
onDragChange={onDragChange}
onDragEnd={() => onTravelTimeDragEnd(index)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
destinationDropdownPortal={destinationDropdownPortal}
/>
</div>
))}
<PoiDistanceFilterCard
features={features}
poiFeature={feature}
filters={filters}
activeFeature={activeFeature}
dragValue={dragValue}
pinnedFeature={pinnedFeature}
filterImpact={poiBackendName ? filterImpacts?.[poiBackendName] : undefined}
percentileScale={
poiBackendName ? percentileScales.get(poiBackendName) : undefined
}
onFilterChange={onFilterChange}
onDragStart={onDragStart}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
onTogglePin={onTogglePin}
onShowInfo={setActiveInfoFeature}
onRemove={() => onRemoveFilter(feature.name)}
/>
</Fragment>
);
}
if (feature.type === 'enum') { if (feature.type === 'enum') {
const selectedValues = (filters[feature.name] as string[]) || []; const selectedValues = (filters[feature.name] as string[]) || [];
const allValues = feature.values || []; const allValues = feature.values || [];
@ -1360,11 +2024,7 @@ export default memo(function Filters({
<div <div
className={`flex flex-col md:min-h-0 border-t border-warm-200 dark:border-warm-700 ${ className={`flex flex-col md:min-h-0 border-t border-warm-200 dark:border-warm-700 ${
addFilterCollapsed addFilterCollapsed && isLicensed ? 'md:[flex:0_0_auto]' : 'md:[flex:1_1_0]'
? 'md:[flex:0_0_auto]'
: activeFilterCollapsed
? 'md:[flex:1_1_0]'
: 'md:[flex:2_1_0]'
}`} }`}
> >
<button <button
@ -1379,65 +2039,100 @@ export default memo(function Filters({
className="w-4 h-4 text-warm-400 dark:text-warm-500" className="w-4 h-4 text-warm-400 dark:text-warm-500"
/> />
</button> </button>
{!addFilterCollapsed && ( {(!addFilterCollapsed || !isLicensed) && (
<div className="md:flex-1 md:min-h-0 md:overflow-y-auto"> <div className="flex min-h-0 flex-1 flex-col">
<FeatureBrowser <div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
availableFeatures={availableFeatures} {!addFilterCollapsed && (
allFeatures={[...features, schoolMeta, specificCrimeMeta]} <FeatureBrowser
pinnedFeature={ availableFeatures={availableFeatures}
pinnedFeature && isSchoolFilterName(pinnedFeature) allFeatures={[
? SCHOOL_FILTER_NAME ...features,
: pinnedFeature && isSpecificCrimeFilterName(pinnedFeature) schoolMeta,
? SPECIFIC_CRIMES_FILTER_NAME specificCrimeMeta,
: pinnedFeature ethnicityMeta,
} poiDistanceMeta,
onAddFilter={handleAddAndScroll} poiCount2KmMeta,
onTogglePin={(name) => { poiCount5KmMeta,
if (name === SCHOOL_FILTER_NAME) { ]}
if (defaultSchoolFeatureName) onTogglePin(defaultSchoolFeatureName); pinnedFeature={
return; pinnedFeature && isSchoolFilterName(pinnedFeature)
} ? SCHOOL_FILTER_NAME
if (name === SPECIFIC_CRIMES_FILTER_NAME) { : pinnedFeature && isSpecificCrimeFilterName(pinnedFeature)
if (defaultSpecificCrimeFeatureName) onTogglePin(defaultSpecificCrimeFeatureName); ? SPECIFIC_CRIMES_FILTER_NAME
return; : pinnedFeature && isEthnicityFilterName(pinnedFeature)
} ? ETHNICITIES_FILTER_NAME
onTogglePin(name); : pinnedFeature && isPoiDistanceFilterName(pinnedFeature)
}} ? (getPoiFilterName(pinnedFeature) ?? POI_DISTANCE_FILTER_NAME)
onNavigateToSource={onNavigateToSource} : pinnedFeature
openInfoFeature={openInfoFeature} }
onClearOpenInfoFeature={onClearOpenInfoFeature} onAddFilter={handleAddAndScroll}
travelTimeEntries={travelTimeEntries} onTogglePin={(name) => {
onAddTravelTimeEntry={handleAddTravelTimeAndScroll} if (name === SCHOOL_FILTER_NAME) {
/> if (defaultSchoolFeatureName) onTogglePin(defaultSchoolFeatureName);
{!isLicensed && ( return;
<div className="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"> if (name === SPECIFIC_CRIMES_FILTER_NAME) {
{t('filters.upgradePrompt')} if (defaultSpecificCrimeFeatureName)
</p> onTogglePin(defaultSpecificCrimeFeatureName);
<p className="text-xs text-warm-400 dark:text-warm-500 text-center mb-4"> return;
{t('filters.oneTimeLifetime')} }
</p> if (name === ETHNICITIES_FILTER_NAME) {
<button if (defaultEthnicityFeatureName) onTogglePin(defaultEthnicityFeatureName);
onClick={onUpgradeClick} return;
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" }
> if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
{t('filters.upgradeToFullMap')} const defaultPoiFeatureName =
</button> defaultPoiFilterFeatureNames[name as PoiFilterName];
<svg if (defaultPoiFeatureName) onTogglePin(defaultPoiFeatureName);
viewBox="0 120 1600 230" return;
className="w-full mt-4 block shrink-0" }
preserveAspectRatio="xMidYMax meet" onTogglePin(name);
> }}
<path onNavigateToSource={onNavigateToSource}
d="M0,350 C400,150 1200,150 1600,350 Z" openInfoFeature={openInfoFeature}
className="fill-green-500 dark:fill-green-600" onClearOpenInfoFeature={onClearOpenInfoFeature}
/> travelTimeEntries={travelTimeEntries}
<path d="M100,350 C450,180 1150,180 1500,350 Z" fill="#000" opacity="0.08" /> onAddTravelTimeEntry={handleAddTravelTimeAndScroll}
<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> {!isLicensed && (
</div> <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> </div>
)} )}
</div> </div>

View file

@ -5,6 +5,8 @@ import { formatValue } from '../../lib/format';
import { ts } from '../../i18n/server'; import { ts } from '../../i18n/server';
import { SCHOOL_FILTER_NAME, getSchoolBackendFeatureName } from '../../lib/school-filter'; import { SCHOOL_FILTER_NAME, getSchoolBackendFeatureName } from '../../lib/school-filter';
import { getSpecificCrimeFeatureName } from '../../lib/crime-filter'; import { getSpecificCrimeFeatureName } from '../../lib/crime-filter';
import { getEthnicityFeatureName } from '../../lib/ethnicity-filter';
import { POI_DISTANCE_FILTER_NAME, getPoiDistanceFeatureName } from '../../lib/poi-distance-filter';
interface HoverCardData { interface HoverCardData {
count: number; count: number;
@ -45,7 +47,14 @@ export default memo(function HoverCard({
for (const name of activeFilterNames.slice(0, 4)) { for (const name of activeFilterNames.slice(0, 4)) {
const schoolBackendName = getSchoolBackendFeatureName(name); const schoolBackendName = getSchoolBackendFeatureName(name);
const specificCrimeFeatureName = getSpecificCrimeFeatureName(name); const specificCrimeFeatureName = getSpecificCrimeFeatureName(name);
const backendName = schoolBackendName ?? specificCrimeFeatureName ?? name; const ethnicityFeatureName = getEthnicityFeatureName(name);
const poiDistanceFeatureName = getPoiDistanceFeatureName(name);
const backendName =
schoolBackendName ??
specificCrimeFeatureName ??
ethnicityFeatureName ??
poiDistanceFeatureName ??
name;
const val = data[`avg_${backendName}`] ?? data[`min_${backendName}`]; const val = data[`avg_${backendName}`] ?? data[`min_${backendName}`];
if (val == null || typeof val !== 'number') continue; if (val == null || typeof val !== 'number') continue;
const meta = featureMap.get(backendName); const meta = featureMap.get(backendName);
@ -54,7 +63,11 @@ export default memo(function HoverCard({
if (label) results.push({ name: backendName, value: ts(label) }); if (label) results.push({ name: backendName, value: ts(label) });
} else { } else {
results.push({ results.push({
name: schoolBackendName ? SCHOOL_FILTER_NAME : backendName, name: schoolBackendName
? SCHOOL_FILTER_NAME
: poiDistanceFeatureName
? POI_DISTANCE_FILTER_NAME
: backendName,
value: formatValue(val, meta), value: formatValue(val, meta),
}); });
} }

View file

@ -1,6 +1,8 @@
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react'; import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
import type { CSSProperties } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Map as MapGL, useControl, ScaleControl } from 'react-map-gl/maplibre'; import { Map as MapGL, useControl, ScaleControl } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox'; import { MapboxOverlay } from '@deck.gl/mapbox';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import type { import type {
@ -18,17 +20,12 @@ import type {
import { import {
zoomToResolution, zoomToResolution,
getBoundsFromViewState, getBoundsFromViewState,
getBoundsWithBottomScreenInset,
getMapStyle, getMapStyle,
getPoiIconUrl, getPoiIconUrl,
getMapCenterForTargetScreenPoint, getMapCenterForTargetScreenPoint,
} from '../../lib/map-utils'; } from '../../lib/map-utils';
import { import { MAP_MIN_ZOOM, MAP_BOUNDS, POI_GROUP_COLORS } from '../../lib/consts';
INITIAL_VIEW_STATE,
MAP_MIN_ZOOM,
MAP_BOUNDS,
POI_GROUP_COLORS,
POI_DEFAULT_COLOR,
} from '../../lib/consts';
import LocationSearch, { type SearchedLocation } from './LocationSearch'; import LocationSearch, { type SearchedLocation } from './LocationSearch';
import MapLegend from './MapLegend'; import MapLegend from './MapLegend';
import HoverCard from './HoverCard'; import HoverCard from './HoverCard';
@ -57,7 +54,7 @@ interface MapProps {
hoveredHexagonId: string | null; hoveredHexagonId: string | null;
onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void; onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void;
onHexagonHover: (h3: string | null, x?: number, y?: number) => void; onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
initialViewState?: ViewState; initialViewState: ViewState;
flyToRef?: React.MutableRefObject< flyToRef?: React.MutableRefObject<
((lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void) | null ((lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void) | null
>; >;
@ -75,6 +72,7 @@ interface MapProps {
travelTimeEntries?: TravelTimeEntry[]; travelTimeEntries?: TravelTimeEntry[];
densityLabel?: string; densityLabel?: string;
totalCount?: number; totalCount?: number;
bottomScreenInset?: number;
} }
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = []; const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
@ -84,6 +82,10 @@ interface Dimensions {
height: number; height: number;
} }
type MapContainerStyle = CSSProperties & {
'--map-mobile-bottom-inset'?: string;
};
function resolveInset( function resolveInset(
pixelValue: number | undefined, pixelValue: number | undefined,
ratioValue: number | undefined, ratioValue: number | undefined,
@ -185,6 +187,27 @@ class SafeMapboxOverlay extends MapboxOverlay {
} }
} }
function getPoiGroupColor(group: string): [number, number, number] {
const color = POI_GROUP_COLORS[group];
if (!color) {
throw new Error(`Missing POI group color for '${group}'`);
}
return color;
}
function getRenderedViewState(map: MapRef | null): ViewState | null {
if (!map) return null;
const center = map.getCenter();
return {
longitude: center.lng,
latitude: center.lat,
zoom: map.getZoom(),
pitch: map.getPitch(),
bearing: map.getBearing(),
};
}
function DeckOverlay({ function DeckOverlay({
layers, layers,
getTooltip, getTooltip,
@ -240,18 +263,18 @@ export default memo(function Map({
travelTimeEntries = EMPTY_TRAVEL_ENTRIES, travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
densityLabel: densityLabelProp, densityLabel: densityLabelProp,
totalCount: totalCountProp, totalCount: totalCountProp,
bottomScreenInset = 0,
}: MapProps) { }: MapProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<MapRef | null>(null);
const { t } = useTranslation(); const { t } = useTranslation();
const modes = useTranslatedModes(); const modes = useTranslatedModes();
const densityLabel = densityLabelProp ?? t('mapLegend.numberOfProperties'); const densityLabel = densityLabelProp ?? t('mapLegend.numberOfProperties');
const [internalViewState, setInternalViewState] = useState<ViewState>( const [internalViewState, setInternalViewState] = useState<ViewState>(initialViewState);
initialViewState || INITIAL_VIEW_STATE
);
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 }); const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
// In screenshot mode, use the prop directly for instant updates (no async lag) // In screenshot mode, use the prop directly for instant updates (no async lag)
const viewState = screenshotMode && initialViewState ? initialViewState : internalViewState; const viewState = screenshotMode ? initialViewState : internalViewState;
useEffect(() => { useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
@ -282,17 +305,33 @@ export default memo(function Map({
useEffect(() => { useEffect(() => {
if (dimensions.width === 0 || dimensions.height === 0) return; if (dimensions.width === 0 || dimensions.height === 0) return;
const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height); let frame = 0;
const resolution = zoomToResolution(viewState.zoom); const emit = () => {
const renderedViewState = getRenderedViewState(mapRef.current);
// mapRef can be null on the very first effect run if MapLibre hasn't
// finished mounting; retry next frame so the initial bounds always reach
// the data hook.
if (!renderedViewState) {
frame = window.requestAnimationFrame(emit);
return;
}
// The bottom sheet can reveal covered map area without a pan/zoom event.
const dataBoundsHeight = dimensions.height + Math.max(0, bottomScreenInset);
const bounds = getBoundsFromViewState(renderedViewState, dimensions.width, dataBoundsHeight);
const resolution = zoomToResolution(renderedViewState.zoom);
onViewChange({ onViewChange({
resolution, resolution,
bounds, bounds,
zoom: viewState.zoom, zoom: renderedViewState.zoom,
latitude: viewState.latitude, latitude: renderedViewState.latitude,
longitude: viewState.longitude, longitude: renderedViewState.longitude,
}); });
}, [viewState, dimensions, onViewChange]); };
frame = window.requestAnimationFrame(emit);
return () => window.cancelAnimationFrame(frame);
}, [viewState, dimensions, bottomScreenInset, onViewChange]);
const handleMove = useCallback((evt: { viewState: ViewState }) => { const handleMove = useCallback((evt: { viewState: ViewState }) => {
setInternalViewState((prev) => { setInternalViewState((prev) => {
@ -342,6 +381,14 @@ export default memo(function Map({
if (flyToRef) flyToRef.current = handleFlyTo; if (flyToRef) flyToRef.current = handleFlyTo;
const mapStyle = useMemo(() => getMapStyle(theme), [theme]); const mapStyle = useMemo(() => getMapStyle(theme), [theme]);
const maxBounds = useMemo(
() => getBoundsWithBottomScreenInset(MAP_BOUNDS, MAP_MIN_ZOOM, bottomScreenInset),
[bottomScreenInset]
);
const mapContainerStyle = useMemo<MapContainerStyle>(
() => (bottomScreenInset > 0 ? { '--map-mobile-bottom-inset': `${bottomScreenInset}px` } : {}),
[bottomScreenInset]
);
const { const {
layers, layers,
@ -374,8 +421,14 @@ export default memo(function Map({
}); });
return ( return (
<div className="flex-1 h-full relative" ref={containerRef} onMouseLeave={handleMouseLeave}> <div
className={`flex-1 h-full relative ${bottomScreenInset > 0 ? 'map-has-mobile-bottom-sheet' : ''}`}
ref={containerRef}
style={mapContainerStyle}
onMouseLeave={handleMouseLeave}
>
<MapGL <MapGL
ref={mapRef}
{...viewState} {...viewState}
onMove={handleMove} onMove={handleMove}
onLoad={undefined} onLoad={undefined}
@ -389,7 +442,7 @@ export default memo(function Map({
keyboard={true} keyboard={true}
pitchWithRotate={false} pitchWithRotate={false}
minZoom={MAP_MIN_ZOOM} minZoom={MAP_MIN_ZOOM}
maxBounds={MAP_BOUNDS} maxBounds={maxBounds}
> >
<DeckOverlay layers={layers} getTooltip={null} /> <DeckOverlay layers={layers} getTooltip={null} />
{!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />} {!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />}
@ -486,6 +539,7 @@ export default memo(function Map({
} }
featureName={colorFeatureMeta.name} featureName={colorFeatureMeta.name}
theme={theme} theme={theme}
suffix={colorFeatureMeta.suffix}
raw={colorFeatureMeta.raw} raw={colorFeatureMeta.raw}
/> />
) : null ) : null
@ -553,7 +607,7 @@ export default memo(function Map({
<span <span
className="inline-block w-2 h-2 rounded-full flex-shrink-0" className="inline-block w-2 h-2 rounded-full flex-shrink-0"
style={{ style={{
backgroundColor: `rgb(${(POI_GROUP_COLORS[popupInfo.group] || POI_DEFAULT_COLOR).join(',')})`, backgroundColor: `rgb(${getPoiGroupColor(popupInfo.group).join(',')})`,
}} }}
/> />
{popupInfo.category} {popupInfo.category}

View file

@ -28,6 +28,22 @@ function ResetScaleIcon({ className = 'w-4 h-4' }: { className?: string }) {
); );
} }
function requireFeatureName(featureName: string | undefined): string {
if (!featureName) {
throw new Error('Enum legend requested without a feature name');
}
return featureName;
}
function requireEnumPalette(
palette: [number, number, number][] | null
): [number, number, number][] {
if (!palette) {
throw new Error('Enum legend requested without a palette');
}
return palette;
}
function EnumSwatches({ function EnumSwatches({
values, values,
palette, palette,
@ -114,7 +130,9 @@ export default function MapLegend({
const { t } = useTranslation(); const { t } = useTranslation();
const isEnum = enumValues && enumValues.length > 0; const isEnum = enumValues && enumValues.length > 0;
const showResetScale = Boolean(onResetScale) && !isEnum; const showResetScale = Boolean(onResetScale) && !isEnum;
const enumPalette = getEnumPaletteForFeature(featureName ?? null, enumValues); const enumPalette = isEnum
? getEnumPaletteForFeature(requireFeatureName(featureName), enumValues)
: null;
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT; const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
const gradientStyle = const gradientStyle =
mode === 'density' mode === 'density'
@ -165,7 +183,7 @@ export default function MapLegend({
</button> </button>
)} )}
{isEnum ? ( {isEnum ? (
<InlineEnumSwatches values={enumValues} palette={enumPalette} /> <InlineEnumSwatches values={enumValues} palette={requireEnumPalette(enumPalette)} />
) : ( ) : (
<div className="flex items-center gap-1.5 flex-1 min-w-[40%] text-warm-500 dark:text-warm-400"> <div className="flex items-center gap-1.5 flex-1 min-w-[40%] text-warm-500 dark:text-warm-400">
{rangeMin} {rangeMin}
@ -213,7 +231,7 @@ export default function MapLegend({
)} )}
</div> </div>
{isEnum ? ( {isEnum ? (
<EnumSwatches values={enumValues} palette={enumPalette} /> <EnumSwatches values={enumValues} palette={requireEnumPalette(enumPalette)} />
) : ( ) : (
<> <>
<div className="h-3 rounded" style={{ background: gradientStyle }} /> <div className="h-3 rounded" style={{ background: gradientStyle }} />

View file

@ -38,10 +38,15 @@ import { canWheelScrollInsideTarget } from '../../lib/dom-scroll';
import { INITIAL_VIEW_STATE } from '../../lib/consts'; import { INITIAL_VIEW_STATE } from '../../lib/consts';
import { getSchoolBackendFeatureName } from '../../lib/school-filter'; import { getSchoolBackendFeatureName } from '../../lib/school-filter';
import { getSpecificCrimeFeatureName } from '../../lib/crime-filter'; import { getSpecificCrimeFeatureName } from '../../lib/crime-filter';
import { getEthnicityFeatureName } from '../../lib/ethnicity-filter';
import { getPoiDistanceFeatureName } from '../../lib/poi-distance-filter';
import { useLicense } from '../../hooks/useLicense'; import { useLicense } from '../../hooks/useLicense';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon'; import { MapPinIcon } from '../ui/icons/MapPinIcon';
import { BookmarkIcon } from '../ui/icons/BookmarkIcon'; import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
import { CheckIcon } from '../ui/icons/CheckIcon';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { InfoIcon } from '../ui/icons/InfoIcon';
const Map = lazy(() => import('./Map')); const Map = lazy(() => import('./Map'));
const Filters = lazy(() => import('./Filters')); const Filters = lazy(() => import('./Filters'));
@ -55,7 +60,71 @@ const MapPageSelectionPane = lazy(() =>
import('./MapPageSelectionPane').then((module) => ({ default: module.MapPageSelectionPane })) import('./MapPageSelectionPane').then((module) => ({ default: module.MapPageSelectionPane }))
); );
const UpgradeModal = lazy(() => import('../ui/UpgradeModal')); const UpgradeModal = lazy(() => import('../ui/UpgradeModal'));
const Joyride = lazy(() => import('react-joyride')); const Joyride = lazy(() => import('react-joyride').then((module) => ({ default: module.Joyride })));
const EXPORT_FILE_NAME = 'perfect-postcode-export.xlsx';
const EXPORT_TIMEOUT_MS = 150_000;
const EXPORT_NOTICE_MS = 6000;
const EXPORT_ERROR_NOTICE_MS = 9000;
type ExportNotice = {
kind: 'success' | 'error';
message: string;
};
function getExportFileName(res: Response): string {
const disposition = res.headers.get('content-disposition');
if (!disposition) return EXPORT_FILE_NAME;
const encodedMatch = disposition.match(/filename\*=UTF-8''([^;]+)/i);
if (encodedMatch?.[1]) {
try {
return decodeURIComponent(encodedMatch[1].trim());
} catch {
return encodedMatch[1].trim();
}
}
const match = disposition.match(/filename="?([^";]+)"?/i);
return match?.[1]?.trim() || EXPORT_FILE_NAME;
}
async function getExportErrorMessage(res: Response): Promise<string> {
const fallback = `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ''}`;
const contentType = res.headers.get('content-type') ?? '';
try {
if (contentType.includes('application/json')) {
const data: unknown = await res.json();
if (data && typeof data === 'object') {
const record = data as Record<string, unknown>;
const message = record.message ?? record.error;
if (typeof message === 'string' && message.trim()) return message.trim();
}
return fallback;
}
const text = await res.text();
return text.trim() || fallback;
} catch {
return fallback;
}
}
function triggerExportDownload(blob: Blob, fileName: string): void {
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = fileName;
link.rel = 'noopener';
link.style.display = 'none';
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
}
function MapFallback() { function MapFallback() {
return ( return (
@ -98,8 +167,8 @@ interface MapPageProps {
initialPostcode?: string; initialPostcode?: string;
shareCode?: string; shareCode?: string;
user?: { id: string; subscription: string; isAdmin?: boolean } | null; user?: { id: string; subscription: string; isAdmin?: boolean } | null;
onLoginClick?: () => void; onLoginClick: () => void;
onRegisterClick?: () => void; onRegisterClick: () => void;
onSaveProperty?: (property: Property) => void; onSaveProperty?: (property: Property) => void;
onUnsaveProperty?: (id: string) => void; onUnsaveProperty?: (id: string) => void;
isPropertySaved?: (address?: string, postcode?: string) => boolean; isPropertySaved?: (address?: string, postcode?: string) => boolean;
@ -146,11 +215,14 @@ export default function MapPage({
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right'); const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [mobileBottomSheetHeight, setMobileBottomSheetHeight] = useState(0);
const [poiPaneOpen, setPoiPaneOpen] = useState(false); const [poiPaneOpen, setPoiPaneOpen] = useState(false);
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null); const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
const [showBookmarkToast, setShowBookmarkToast] = useState(false); const [showBookmarkToast, setShowBookmarkToast] = useState(false);
const bookmarkToastDismissed = useRef(localStorage.getItem('bookmark_toast_dismissed') === '1'); const bookmarkToastDismissed = useRef(localStorage.getItem('bookmark_toast_dismissed') === '1');
const [exportNotice, setExportNotice] = useState<ExportNotice | null>(null);
const exportNoticeTimeoutRef = useRef<number | null>(null);
const handleSavePropertyWithToast = useCallback( const handleSavePropertyWithToast = useCallback(
(property: Property) => { (property: Property) => {
@ -166,6 +238,35 @@ export default function MapPage({
const { t } = useTranslation(); const { t } = useTranslation();
const modes = useTranslatedModes(); const modes = useTranslatedModes();
const clearExportNoticeTimer = useCallback(() => {
if (exportNoticeTimeoutRef.current !== null) {
window.clearTimeout(exportNoticeTimeoutRef.current);
exportNoticeTimeoutRef.current = null;
}
}, []);
const clearExportNotice = useCallback(() => {
clearExportNoticeTimer();
setExportNotice(null);
}, [clearExportNoticeTimer]);
const showExportNotice = useCallback(
(notice: ExportNotice) => {
clearExportNoticeTimer();
setExportNotice(notice);
exportNoticeTimeoutRef.current = window.setTimeout(
() => {
setExportNotice(null);
exportNoticeTimeoutRef.current = null;
},
notice.kind === 'error' ? EXPORT_ERROR_NOTICE_MS : EXPORT_NOTICE_MS
);
},
[clearExportNoticeTimer]
);
useEffect(() => clearExportNoticeTimer, [clearExportNoticeTimer]);
const { const {
filters, filters,
activeFeature, activeFeature,
@ -555,10 +656,16 @@ export default function MapPage({
]); ]);
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial); const tutorial = useTutorial(initialLoading, isMobile, deferTutorial);
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const handleExport = useCallback(() => { const handleExport = useCallback(() => {
if (!mapData.bounds || exporting) return; if (exporting) return;
if (!mapData.bounds) {
showExportNotice({ kind: 'error', message: t('header.exportUnavailable') });
return;
}
const { south, west, north, east } = mapData.bounds; const { south, west, north, east } = mapData.bounds;
const params = new URLSearchParams({ const params = new URLSearchParams({
bounds: `${south},${west},${north},${east}`, bounds: `${south},${west},${north},${east}`,
@ -567,23 +674,48 @@ export default function MapPage({
if (filterStr) params.set('filters', filterStr); if (filterStr) params.set('filters', filterStr);
const url = apiUrl('export', params); const url = apiUrl('export', params);
const controller = new AbortController();
let timedOut = false;
const timeoutId = window.setTimeout(() => {
timedOut = true;
controller.abort();
}, EXPORT_TIMEOUT_MS);
setExporting(true); setExporting(true);
fetch(url, authHeaders()) clearExportNotice();
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`); void (async () => {
return res.blob(); try {
}) const res = await fetch(url, authHeaders({ signal: controller.signal }));
.then((blob) => { if (!res.ok) throw new Error(await getExportErrorMessage(res));
const link = document.createElement('a');
link.href = URL.createObjectURL(blob); const blob = await res.blob();
link.download = 'perfect-postcode-export.xlsx'; if (blob.size === 0) throw new Error(t('header.exportEmpty'));
link.click();
URL.revokeObjectURL(link.href); triggerExportDownload(blob, getExportFileName(res));
trackEvent('Export'); trackEvent('Export');
}) showExportNotice({ kind: 'success', message: t('header.exportReady') });
.catch((err) => logNonAbortError('Export failed', err)) } catch (err) {
.finally(() => setExporting(false)); if (!timedOut) logNonAbortError('Export failed', err);
}, [mapData.bounds, filters, features, exporting]); const detail = err instanceof Error && err.message.trim() ? ` ${err.message}` : '';
showExportNotice({
kind: 'error',
message: timedOut ? t('header.exportTimedOut') : `${t('header.exportFailed')}${detail}`,
});
} finally {
window.clearTimeout(timeoutId);
setExporting(false);
}
})();
}, [
clearExportNotice,
exporting,
features,
filters,
mapData.bounds,
showExportNotice,
t,
]);
useEffect(() => { useEffect(() => {
onExportStateChange?.({ onExport: handleExport, exporting }); onExportStateChange?.({ onExport: handleExport, exporting });
@ -600,6 +732,8 @@ export default function MapPage({
const featureName = viewFeature const featureName = viewFeature
? (getSchoolBackendFeatureName(viewFeature) ?? ? (getSchoolBackendFeatureName(viewFeature) ??
getSpecificCrimeFeatureName(viewFeature) ?? getSpecificCrimeFeatureName(viewFeature) ??
getEthnicityFeatureName(viewFeature) ??
getPoiDistanceFeatureName(viewFeature) ??
viewFeature) viewFeature)
: null; : null;
return featureName ? features.find((f) => f.name === featureName) || null : null; return featureName ? features.find((f) => f.name === featureName) || null : null;
@ -609,6 +743,8 @@ export default function MapPage({
viewFeature viewFeature
? (getSchoolBackendFeatureName(viewFeature) ?? ? (getSchoolBackendFeatureName(viewFeature) ??
getSpecificCrimeFeatureName(viewFeature) ?? getSpecificCrimeFeatureName(viewFeature) ??
getEthnicityFeatureName(viewFeature) ??
getPoiDistanceFeatureName(viewFeature) ??
viewFeature) viewFeature)
: null, : null,
[viewFeature] [viewFeature]
@ -685,6 +821,28 @@ export default function MapPage({
</div> </div>
); );
const exportToast = exportNotice && (
<div
role={exportNotice.kind === 'error' ? 'alert' : 'status'}
aria-live={exportNotice.kind === 'error' ? 'assertive' : 'polite'}
className={`fixed ${showBookmarkToast ? 'bottom-24' : 'bottom-6'} left-1/2 z-[60] flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-3 rounded-lg bg-navy-900 px-4 py-3 text-sm text-white shadow-lg animate-fade-in`}
>
{exportNotice.kind === 'success' ? (
<CheckIcon className="h-4 w-4 shrink-0 text-teal-400" />
) : (
<InfoIcon className="h-4 w-4 shrink-0 text-red-300" />
)}
<span className="min-w-0">{exportNotice.message}</span>
<button
onClick={clearExportNotice}
aria-label={t('common.close')}
className="-mr-1 flex h-7 w-7 shrink-0 items-center justify-center rounded text-warm-300 hover:bg-navy-800 hover:text-white"
>
<CloseIcon className="h-4 w-4" />
</button>
</div>
);
if (screenshotMode) { if (screenshotMode) {
return ( return (
<div className="h-full w-full"> <div className="h-full w-full">
@ -805,7 +963,7 @@ export default function MapPage({
aiFilterSummary={aiFilterSummary} aiFilterSummary={aiFilterSummary}
onAiFilterSubmit={handleAiFilterSubmit} onAiFilterSubmit={handleAiFilterSubmit}
isLoggedIn={!!user} isLoggedIn={!!user}
onLoginRequired={onRegisterClick ?? (() => {})} onLoginRequired={onRegisterClick}
isLicensed={user?.subscription === 'licensed'} isLicensed={user?.subscription === 'licensed'}
onUpgradeClick={() => onNavigateTo('pricing')} onUpgradeClick={() => onNavigateTo('pricing')}
onResetTutorial={!isMobile ? tutorial.resetTutorial : undefined} onResetTutorial={!isMobile ? tutorial.resetTutorial : undefined}
@ -859,6 +1017,7 @@ export default function MapPage({
featureName={mobileLegendMeta.name} featureName={mobileLegendMeta.name}
theme={theme} theme={theme}
inline inline
suffix={mobileLegendMeta.suffix}
raw={mobileLegendMeta.raw} raw={mobileLegendMeta.raw}
/> />
); );
@ -926,21 +1085,11 @@ export default function MapPage({
hideLegend hideLegend
hideLocationSearch={mobileDrawerOpen && !!selectedHexagon} hideLocationSearch={mobileDrawerOpen && !!selectedHexagon}
travelTimeEntries={entries} travelTimeEntries={entries}
bottomScreenInset={mobileBottomSheetHeight}
/> />
</Suspense> </Suspense>
</div> </div>
{mapData.loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center pointer-events-none">
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">
Loading...
</span>
</div>
</div>
)}
<button <button
onClick={() => setPoiPaneOpen((p) => !p)} onClick={() => setPoiPaneOpen((p) => !p)}
className={`absolute top-3 right-3 z-20 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`} className={`absolute top-3 right-3 z-20 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
@ -955,7 +1104,10 @@ export default function MapPage({
</div> </div>
)} )}
<MobileBottomSheet legend={renderMobileLegend()}> <MobileBottomSheet
legend={renderMobileLegend()}
onCoveredHeightChange={setMobileBottomSheetHeight}
>
{renderFilters({ destinationDropdownPortal: false })} {renderFilters({ destinationDropdownPortal: false })}
</MobileBottomSheet> </MobileBottomSheet>
@ -979,13 +1131,14 @@ export default function MapPage({
)} )}
{bookmarkToast} {bookmarkToast}
{exportToast}
{mapData.licenseRequired && ( {mapData.licenseRequired && (
<Suspense fallback={null}> <Suspense fallback={null}>
<UpgradeModal <UpgradeModal
isLoggedIn={!!user} isLoggedIn={!!user}
onLoginClick={onLoginClick ?? (() => {})} onLoginClick={onLoginClick}
onRegisterClick={onRegisterClick ?? (() => {})} onRegisterClick={onRegisterClick}
onStartCheckout={() => license.startCheckout()} onStartCheckout={() => license.startCheckout()}
onZoomToFreeZone={handleZoomToFreeZone} onZoomToFreeZone={handleZoomToFreeZone}
isShareReturn={!!shareReturnViewRef.current} isShareReturn={!!shareReturnViewRef.current}
@ -1015,11 +1168,14 @@ export default function MapPage({
steps={tutorial.steps} steps={tutorial.steps}
run={tutorial.run} run={tutorial.run}
continuous continuous
showProgress onEvent={tutorial.handleCallback}
showSkipButton styles={tutorialTheme.styles}
callback={tutorial.handleCallback} options={{
styles={getTutorialStyles(theme)} ...tutorialTheme.options,
disableScrolling buttons: ['back', 'close', 'primary', 'skip'],
showProgress: true,
skipScroll: true,
}}
locale={{ last: 'Finish' }} locale={{ last: 'Finish' }}
/> />
</Suspense> </Suspense>
@ -1044,6 +1200,15 @@ export default function MapPage({
</div> </div>
<div data-tutorial="map" className="flex-1 relative"> <div data-tutorial="map" className="flex-1 relative">
{tutorial.run && (
<>
<div
data-tutorial="map-anchor"
aria-hidden="true"
className="pointer-events-none absolute left-1/2 top-1/2 z-20 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-teal-500 shadow-lg ring-4 ring-teal-500/30 dark:border-navy-950"
/>
</>
)}
<Suspense fallback={<MapFallback />}> <Suspense fallback={<MapFallback />}>
<Map <Map
data={mapData.data} data={mapData.data}
@ -1077,16 +1242,6 @@ export default function MapPage({
totalCount={hasActiveFilters ? filterCounts.total : undefined} totalCount={hasActiveFilters ? filterCounts.total : undefined}
/> />
</Suspense> </Suspense>
{mapData.loading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">
Loading...
</span>
</div>
</div>
)}
{/* Floating POI button */} {/* Floating POI button */}
<button <button
data-tutorial="poi-button" data-tutorial="poi-button"
@ -1120,13 +1275,14 @@ export default function MapPage({
)} )}
{bookmarkToast} {bookmarkToast}
{exportToast}
{mapData.licenseRequired && ( {mapData.licenseRequired && (
<Suspense fallback={null}> <Suspense fallback={null}>
<UpgradeModal <UpgradeModal
isLoggedIn={!!user} isLoggedIn={!!user}
onLoginClick={onLoginClick ?? (() => {})} onLoginClick={onLoginClick}
onRegisterClick={onRegisterClick ?? (() => {})} onRegisterClick={onRegisterClick}
onStartCheckout={() => license.startCheckout()} onStartCheckout={() => license.startCheckout()}
onZoomToFreeZone={handleZoomToFreeZone} onZoomToFreeZone={handleZoomToFreeZone}
isShareReturn={!!shareReturnViewRef.current} isShareReturn={!!shareReturnViewRef.current}

View file

@ -37,6 +37,11 @@ export type Page =
| 'invites' | 'invites'
| 'invite'; | 'invite';
export interface HeaderExportState {
onExport: () => void;
exporting: boolean;
}
export const PAGE_PATHS: Record<Page, string> = { export const PAGE_PATHS: Record<Page, string> = {
home: '/', home: '/',
dashboard: '/dashboard', dashboard: '/dashboard',
@ -59,13 +64,14 @@ export const PAGE_PATHS: Record<Page, string> = {
invite: '/invite', invite: '/invite',
}; };
const DASHBOARD_TABLET_SIDEBAR_QUERY = '(min-width: 768px) and (max-width: 1023px)';
export default function Header({ export default function Header({
activePage, activePage,
onPageChange, onPageChange,
theme, theme,
onToggleTheme, onToggleTheme,
onExport, exportState,
exporting,
onSaveSearch, onSaveSearch,
savingSearch, savingSearch,
user, user,
@ -78,8 +84,7 @@ export default function Header({
onPageChange: (page: Page) => void; onPageChange: (page: Page) => void;
theme: 'light' | 'dark'; theme: 'light' | 'dark';
onToggleTheme: () => void; onToggleTheme: () => void;
onExport: (() => void) | null; exportState: HeaderExportState | null;
exporting: boolean;
onSaveSearch: (() => void) | null; onSaveSearch: (() => void) | null;
savingSearch: boolean; savingSearch: boolean;
user: AuthUser | null; user: AuthUser | null;
@ -92,6 +97,17 @@ export default function Header({
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [sharing, setSharing] = useState(false); const [sharing, setSharing] = useState(false);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [isDashboardTabletSidebarWidth, setIsDashboardTabletSidebarWidth] = useState(() =>
window.matchMedia(DASHBOARD_TABLET_SIDEBAR_QUERY).matches
);
const useSidebarNav = isMobile || (activePage === 'dashboard' && isDashboardTabletSidebarWidth);
useEffect(() => {
const mql = window.matchMedia(DASHBOARD_TABLET_SIDEBAR_QUERY);
const handler = (e: MediaQueryListEvent) => setIsDashboardTabletSidebarWidth(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);
// Close menu on Escape // Close menu on Escape
useEffect(() => { useEffect(() => {
@ -103,10 +119,10 @@ export default function Header({
return () => window.removeEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler);
}, [menuOpen]); }, [menuOpen]);
// Close menu when switching away from mobile // Close menu when switching away from the sidebar-capable header.
useEffect(() => { useEffect(() => {
if (!isMobile) setMenuOpen(false); if (!useSidebarNav) setMenuOpen(false);
}, [isMobile]); }, [useSidebarNav]);
const doCopy = useCallback((text: string) => { const doCopy = useCallback((text: string) => {
copyToClipboard(text, () => { copyToClipboard(text, () => {
@ -140,7 +156,7 @@ export default function Header({
}; };
const tabClass = (page: Page) => const tabClass = (page: Page) =>
`inline-flex cursor-pointer items-center px-3 py-1.5 rounded text-sm font-medium transition-colors ${ `inline-flex cursor-pointer items-center px-4 py-1.5 rounded text-sm font-medium transition-colors ${
activePage === page activePage === page
? 'bg-navy-700 text-white' ? 'bg-navy-700 text-white'
: 'text-warm-300 hover:bg-navy-800 hover:text-white' : 'text-warm-300 hover:bg-navy-800 hover:text-white'
@ -161,8 +177,8 @@ export default function Header({
</a> </a>
{/* Desktop nav */} {/* Desktop nav */}
{!isMobile && ( {!useSidebarNav && (
<nav className="flex items-center gap-2"> <nav className="top-menu flex items-center">
<a <a
href={PAGE_PATHS.dashboard} href={PAGE_PATHS.dashboard}
className={tabClass('dashboard')} className={tabClass('dashboard')}
@ -202,12 +218,12 @@ export default function Header({
{/* Right side */} {/* Right side */}
<div className="flex items-center gap-2 ml-auto"> <div className="flex items-center gap-2 ml-auto">
{/* Desktop-only dashboard actions */} {/* Desktop-only dashboard actions */}
{!isMobile && activePage === 'dashboard' && ( {!useSidebarNav && activePage === 'dashboard' && (
<> <>
<button <button
onClick={handleShare} onClick={handleShare}
disabled={sharing} disabled={sharing}
className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:cursor-wait disabled:opacity-50" className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50"
> >
{sharing ? ( {sharing ? (
<> <>
@ -226,20 +242,22 @@ export default function Header({
</> </>
)} )}
</button> </button>
<button {exportState && (
onClick={onExport ?? undefined} <button
disabled={!onExport || exporting} onClick={exportState.onExport}
className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:cursor-wait disabled:opacity-50" disabled={exportState.exporting}
title={t('header.exportToExcel')} className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50"
> title={t('header.exportToExcel')}
<DownloadIcon className="w-4 h-4" /> >
{exporting ? t('header.exporting') : t('header.exportLabel')} <DownloadIcon className="w-4 h-4" />
</button> {exportState.exporting ? t('header.exporting') : t('header.exportLabel')}
</button>
)}
{onSaveSearch && ( {onSaveSearch && (
<button <button
onClick={onSaveSearch} onClick={onSaveSearch}
disabled={savingSearch} disabled={savingSearch}
className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:cursor-wait disabled:opacity-50" className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50"
> >
{savingSearch ? ( {savingSearch ? (
<SpinnerIcon className="w-4 h-4 animate-spin" /> <SpinnerIcon className="w-4 h-4 animate-spin" />
@ -251,7 +269,7 @@ export default function Header({
)} )}
</> </>
)} )}
{!isMobile && user && ( {!useSidebarNav && user && (
<a <a
href={PAGE_PATHS.saved} href={PAGE_PATHS.saved}
className={tabClass('saved')} className={tabClass('saved')}
@ -262,7 +280,7 @@ export default function Header({
)} )}
{/* Desktop-only auth */} {/* Desktop-only auth */}
{!isMobile && ( {!useSidebarNav && (
<> <>
{user ? ( {user ? (
<UserMenu <UserMenu
@ -292,7 +310,7 @@ export default function Header({
)} )}
{/* Mobile auth CTA (logged out only) */} {/* Mobile auth CTA (logged out only) */}
{isMobile && !user && ( {useSidebarNav && !user && (
<button <button
onClick={onRegisterClick} onClick={onRegisterClick}
className="cursor-pointer px-4 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-semibold" className="cursor-pointer px-4 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-semibold"
@ -302,10 +320,10 @@ export default function Header({
)} )}
{/* Language selector (desktop) */} {/* Language selector (desktop) */}
{!isMobile && <LanguageDropdown />} {!useSidebarNav && <LanguageDropdown />}
{/* Theme toggle (desktop, logged-out only — logged-in users use UserMenu) */} {/* Theme toggle (desktop, logged-out only — logged-in users use UserMenu) */}
{!isMobile && !user && ( {!useSidebarNav && !user && (
<button <button
onClick={onToggleTheme} onClick={onToggleTheme}
className="flex cursor-pointer items-center justify-center w-8 h-8 rounded bg-navy-800 hover:bg-navy-700 transition-colors" className="flex cursor-pointer items-center justify-center w-8 h-8 rounded bg-navy-800 hover:bg-navy-700 transition-colors"
@ -320,7 +338,7 @@ export default function Header({
)} )}
{/* Mobile hamburger */} {/* Mobile hamburger */}
{isMobile && ( {useSidebarNav && (
<button <button
onClick={() => setMenuOpen(true)} onClick={() => setMenuOpen(true)}
className="flex cursor-pointer items-center justify-center w-10 h-10 -mr-2 rounded hover:bg-navy-800 transition-colors" className="flex cursor-pointer items-center justify-center w-10 h-10 -mr-2 rounded hover:bg-navy-800 transition-colors"
@ -333,14 +351,13 @@ export default function Header({
</header> </header>
{/* Mobile slide-in menu */} {/* Mobile slide-in menu */}
{isMobile && menuOpen && ( {useSidebarNav && menuOpen && (
<MobileMenu <MobileMenu
activePage={activePage} activePage={activePage}
onPageChange={onPageChange} onPageChange={onPageChange}
theme={theme} theme={theme}
onToggleTheme={onToggleTheme} onToggleTheme={onToggleTheme}
onExport={onExport} exportState={exportState}
exporting={exporting}
onSaveSearch={onSaveSearch} onSaveSearch={onSaveSearch}
savingSearch={savingSearch} savingSearch={savingSearch}
user={user} user={user}
@ -354,7 +371,7 @@ export default function Header({
/> />
)} )}
{/* Mobile "Copied" toast */} {/* Mobile "Copied" toast */}
{isMobile && copied && ( {useSidebarNav && copied && (
<div className="fixed top-14 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-2 px-4 py-2 rounded-lg bg-navy-900 text-white text-sm shadow-lg animate-fade-in"> <div className="fixed top-14 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-2 px-4 py-2 rounded-lg bg-navy-900 text-white text-sm shadow-lg animate-fade-in">
<CheckIcon className="w-4 h-4 text-teal-400" /> <CheckIcon className="w-4 h-4 text-teal-400" />
{t('common.copiedToClipboard')} {t('common.copiedToClipboard')}

View file

@ -3,6 +3,7 @@ import type { ReactNode, MouseEvent } from 'react';
interface IconButtonProps { interface IconButtonProps {
onClick: (e: MouseEvent<HTMLButtonElement>) => void; onClick: (e: MouseEvent<HTMLButtonElement>) => void;
title?: string; title?: string;
ariaLabel?: string;
children: ReactNode; children: ReactNode;
active?: boolean; active?: boolean;
className?: string; className?: string;
@ -12,6 +13,7 @@ interface IconButtonProps {
export function IconButton({ export function IconButton({
onClick, onClick,
title, title,
ariaLabel,
children, children,
active, active,
className, className,
@ -24,8 +26,11 @@ export function IconButton({
return ( return (
<button <button
type="button"
onClick={onClick} onClick={onClick}
title={title} title={title}
aria-label={ariaLabel ?? title}
aria-pressed={active === undefined ? undefined : active}
className={`${padClasses} rounded ${colorClasses} ${className || ''}`} className={`${padClasses} rounded ${colorClasses} ${className || ''}`}
> >
{children} {children}

View file

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { Page } from './Header'; import type { HeaderExportState, Page } from './Header';
import { PAGE_PATHS } from './Header'; import { PAGE_PATHS } from './Header';
import type { AuthUser } from '../../hooks/useAuth'; import type { AuthUser } from '../../hooks/useAuth';
import { changeLanguage as changeAppLanguage, SUPPORTED_LANGUAGES } from '../../i18n'; import { changeLanguage as changeAppLanguage, SUPPORTED_LANGUAGES } from '../../i18n';
@ -17,8 +17,7 @@ interface MobileMenuProps {
onPageChange: (page: Page) => void; onPageChange: (page: Page) => void;
theme: 'light' | 'dark'; theme: 'light' | 'dark';
onToggleTheme: () => void; onToggleTheme: () => void;
onExport: (() => void) | null; exportState: HeaderExportState | null;
exporting: boolean;
onSaveSearch: (() => void) | null; onSaveSearch: (() => void) | null;
savingSearch: boolean; savingSearch: boolean;
user: AuthUser | null; user: AuthUser | null;
@ -36,8 +35,7 @@ export default function MobileMenu({
onPageChange, onPageChange,
theme, theme,
onToggleTheme, onToggleTheme,
onExport, exportState,
exporting,
onSaveSearch, onSaveSearch,
savingSearch, savingSearch,
user, user,
@ -50,6 +48,9 @@ export default function MobileMenu({
sharing, sharing,
}: MobileMenuProps) { }: MobileMenuProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const emailParts = user?.email.split('@');
const emailLocal = emailParts?.[0] ?? '';
const emailDomain = emailParts && emailParts.length > 1 ? emailParts.slice(1).join('@') : '';
const mobileNavItem = (page: Page, label: string) => ( const mobileNavItem = (page: Page, label: string) => (
<a <a
@ -72,7 +73,7 @@ export default function MobileMenu({
); );
const dashboardActionClass = const dashboardActionClass =
'w-full flex cursor-pointer items-center justify-center gap-2 px-3 py-2 rounded bg-navy-800 text-sm font-semibold text-white border border-navy-700 shadow-sm hover:bg-navy-700 disabled:cursor-wait disabled:opacity-50 transition-colors'; 'w-full flex cursor-pointer items-center justify-center gap-2 px-3 py-2 rounded bg-navy-800 text-sm font-semibold text-white border border-navy-700 shadow-sm hover:bg-navy-700 disabled:opacity-50 transition-colors';
const dashboardSavedItem = user && ( const dashboardSavedItem = user && (
<a <a
@ -109,17 +110,19 @@ export default function MobileMenu({
)} )}
{sharing ? t('header.sharing') : copied ? t('common.copied') : t('common.share')} {sharing ? t('header.sharing') : copied ? t('common.copied') : t('common.share')}
</button> </button>
<button {exportState && (
onClick={() => { <button
onExport?.(); onClick={() => {
onClose(); exportState.onExport();
}} onClose();
disabled={!onExport || exporting} }}
className={dashboardActionClass} disabled={exportState.exporting}
> className={dashboardActionClass}
<DownloadIcon className="w-4 h-4" /> >
{exporting ? t('header.exporting') : t('header.exportLabel')} <DownloadIcon className="w-4 h-4" />
</button> {exportState.exporting ? t('header.exporting') : t('header.exportLabel')}
</button>
)}
{onSaveSearch && ( {onSaveSearch && (
<button <button
onClick={() => { onClick={() => {
@ -210,7 +213,21 @@ export default function MobileMenu({
<div> <div>
{user ? ( {user ? (
<div className="flex items-center justify-between gap-2 px-3 py-1.5"> <div className="flex items-center justify-between gap-2 px-3 py-1.5">
<span className="text-sm text-warm-300 truncate">{user.email}</span> <span
className="min-w-0 text-sm text-warm-300 truncate"
aria-label={user.email}
title={user.email}
>
{emailDomain ? (
<>
<span aria-hidden="true">{emailLocal}</span>
<span aria-hidden="true" className="after:content-['@']" />
<span aria-hidden="true">{emailDomain}</span>
</>
) : (
user.email
)}
</span>
<button <button
onClick={() => { onClick={() => {
onLogout(); onLogout();

View file

@ -30,10 +30,10 @@ export function PillToggle({
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className={`${sizeClasses} ${colorClasses} inline-flex items-center gap-1.5 rounded-full font-medium whitespace-nowrap cursor-pointer`} className={`${sizeClasses} ${colorClasses} inline-flex max-w-full shrink-0 items-center gap-1.5 rounded-full font-medium whitespace-nowrap cursor-pointer`}
> >
{icon} {icon}
{label} <span className="block min-w-0 truncate">{label}</span>
</button> </button>
); );
} }

View file

@ -19,7 +19,7 @@ export function Slider({ className, ...props }: SliderProps) {
{props.value?.map((_, i) => ( {props.value?.map((_, i) => (
<SliderPrimitive.Thumb <SliderPrimitive.Thumb
key={i} key={i}
className="block h-6 w-6 cursor-pointer rounded-full border-2 border-teal-600 dark:border-teal-500 bg-white dark:bg-navy-800 ring-offset-white dark:ring-offset-navy-950 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 before:absolute before:left-1/2 before:top-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:h-11 before:w-11 before:rounded-full before:content-['']" className="relative block h-6 w-6 cursor-pointer rounded-full border-2 border-teal-600 bg-white ring-offset-white transition-colors before:absolute before:left-1/2 before:top-1/2 before:h-14 before:w-14 before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:content-[''] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:border-teal-500 dark:bg-navy-800 dark:ring-offset-navy-950 md:before:h-11 md:before:w-11"
/> />
))} ))}
</SliderPrimitive.Root> </SliderPrimitive.Root>

View file

@ -56,6 +56,15 @@ function distToRatios(dist: unknown): number[] {
return r; return r;
} }
function requireEnumPalette(
palette: [number, number, number][] | null
): [number, number, number][] {
if (!palette) {
throw new Error('Enum layer requested without an enum color palette');
}
return palette;
}
export function useDeckLayers({ export function useDeckLayers({
data, data,
postcodeData, postcodeData,
@ -127,9 +136,12 @@ export function useDeckLayers({
? colorFeatureMeta.values.length ? colorFeatureMeta.values.length
: 0; : 0;
// Per-feature color palette (uses overrides when defined) const enumPalette =
const enumPaletteRef = useRef(getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values)); viewFeature && colorFeatureMeta?.type === 'enum' && colorFeatureMeta.values
enumPaletteRef.current = getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values); ? getEnumPaletteForFeature(viewFeature, colorFeatureMeta.values)
: null;
const enumPaletteRef = useRef(enumPalette);
enumPaletteRef.current = enumPalette;
const countRange = useMemo(() => { const countRange = useMemo(() => {
if (data.length === 0) return { min: 0, max: 1, total: 0 }; if (data.length === 0) return { min: 0, max: 1, total: 0 };
@ -256,27 +268,27 @@ export function useDeckLayers({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const pieProps: any = isEnum const pieProps: any = isEnum
? { ? {
extensions: [new PieHexExtension(enumPaletteRef.current)], extensions: [new PieHexExtension(requireEnumPalette(enumPaletteRef.current))],
getCenter: (d: HexagonData) => [d.lon, d.lat], getCenter: (d: HexagonData) => [d.lon, d.lat],
getRatios0: (d: HexagonData) => { getRatios0: (d: HexagonData) => {
const r = distToRatios(d[distKey]); const r = distToRatios(d[distKey]);
return [r[0], r[1], r[2], r[3]]; return [r[0], r[1], r[2], r[3]];
}, },
getRatios1: (d: HexagonData) => { getRatios1: (d: HexagonData) => {
const r = distToRatios(d[distKey]); const r = distToRatios(d[distKey]);
return [r[4], r[5], r[6], r[7]]; return [r[4], r[5], r[6], r[7]];
}, },
getRatios2: (d: HexagonData) => { getRatios2: (d: HexagonData) => {
const r = distToRatios(d[distKey]); const r = distToRatios(d[distKey]);
return [r[8], r[9]]; return [r[8], r[9]];
}, },
updateTriggers: { updateTriggers: {
getCenter: [colorTrigger, data], getCenter: [colorTrigger, data],
getRatios0: [colorTrigger, data], getRatios0: [colorTrigger, data],
getRatios1: [colorTrigger, data], getRatios1: [colorTrigger, data],
getRatios2: [colorTrigger, data], getRatios2: [colorTrigger, data],
}, },
} }
: {}; : {};
return new H3HexagonLayer<HexagonData>({ return new H3HexagonLayer<HexagonData>({
@ -568,11 +580,15 @@ export function useDeckLayers({
const layers = useMemo(() => { const layers = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const baseLayers: any[] = usePostcodeView const baseLayers: any[] = [];
? zoom >= 16 if (usePostcodeView) {
? [postcodeLayer, postcodeLabelsLayer, ...poiLayers] baseLayers.push(postcodeLayer);
: [postcodeLayer, ...poiLayers] if (zoom >= 16) baseLayers.push(postcodeLabelsLayer);
: [hexLayer, ...poiLayers]; baseLayers.push(...poiLayers);
} else {
baseLayers.push(hexLayer);
baseLayers.push(...poiLayers);
}
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer); if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
if (currentLocationLayer) baseLayers.push(currentLocationLayer); if (currentLocationLayer) baseLayers.push(currentLocationLayer);
return baseLayers; return baseLayers;

View file

@ -17,6 +17,23 @@ import {
getSpecificCrimeFilterKeyId, getSpecificCrimeFilterKeyId,
normalizeSpecificCrimeFilters, normalizeSpecificCrimeFilters,
} from '../lib/crime-filter'; } from '../lib/crime-filter';
import {
ETHNICITIES_FILTER_NAME,
createEthnicityFilterKey,
getDefaultEthnicityFeatureName,
getEthnicityFeatureName,
getEthnicityFilterKeyId,
normalizeEthnicityFilters,
} from '../lib/ethnicity-filter';
import {
POI_FILTER_NAMES,
createPoiFilterKey,
getDefaultPoiFilterFeatureName,
getPoiDistanceFeatureName,
getPoiDistanceFilterKeyId,
normalizePoiDistanceFilters,
type PoiFilterName,
} from '../lib/poi-distance-filter';
interface UseFiltersOptions { interface UseFiltersOptions {
initialFilters: FeatureFilters; initialFilters: FeatureFilters;
@ -24,11 +41,19 @@ interface UseFiltersOptions {
} }
function normalizeFilters(filters: FeatureFilters): FeatureFilters { function normalizeFilters(filters: FeatureFilters): FeatureFilters {
return normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters)); return normalizePoiDistanceFilters(
normalizeEthnicityFilters(normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters)))
);
} }
function getBackendFeatureName(name: string): string { function getBackendFeatureName(name: string): string {
return getSchoolBackendFeatureName(name) ?? getSpecificCrimeFeatureName(name) ?? name; return (
getSchoolBackendFeatureName(name) ??
getSpecificCrimeFeatureName(name) ??
getEthnicityFeatureName(name) ??
getPoiDistanceFeatureName(name) ??
name
);
} }
function dropUnknownFilters(filters: FeatureFilters, features: FeatureMeta[]): FeatureFilters { function dropUnknownFilters(filters: FeatureFilters, features: FeatureMeta[]): FeatureFilters {
@ -85,6 +110,12 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const specificCrimeFilterIdRef = useRef( const specificCrimeFilterIdRef = useRef(
getNextNumericKeyId(initialFiltersRef.current!, getSpecificCrimeFilterKeyId) getNextNumericKeyId(initialFiltersRef.current!, getSpecificCrimeFilterKeyId)
); );
const ethnicityFilterIdRef = useRef(
getNextNumericKeyId(initialFiltersRef.current!, getEthnicityFilterKeyId)
);
const poiDistanceFilterIdRef = useRef(
getNextNumericKeyId(initialFiltersRef.current!, getPoiDistanceFilterKeyId)
);
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]); const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
@ -117,7 +148,15 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
const handleAddFilter = useCallback( const handleAddFilter = useCallback(
(name: string) => { (name: string) => {
const meta = features.find((f) => f.name === name); const meta = features.find((f) => f.name === name);
if (name !== SCHOOL_FILTER_NAME && name !== SPECIFIC_CRIMES_FILTER_NAME && !meta) return; if (
name !== SCHOOL_FILTER_NAME &&
name !== SPECIFIC_CRIMES_FILTER_NAME &&
name !== ETHNICITIES_FILTER_NAME &&
!POI_FILTER_NAMES.includes(name as PoiFilterName) &&
!meta
) {
return;
}
trackEvent('Filter Add', { feature: name }); trackEvent('Filter Add', { feature: name });
setFilters((prev) => { setFilters((prev) => {
undoStackRef.current.push(prev); undoStackRef.current.push(prev);
@ -159,6 +198,42 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
], ],
}; };
} }
if (name === ETHNICITIES_FILTER_NAME) {
const defaultEthnicityFeatureName = getDefaultEthnicityFeatureName(features);
const defaultEthnicityFeature = defaultEthnicityFeatureName
? features.find((feature) => feature.name === defaultEthnicityFeatureName)
: undefined;
if (!defaultEthnicityFeatureName) return prev;
return {
...prev,
[createEthnicityFilterKey(defaultEthnicityFeatureName, ethnicityFilterIdRef.current++)]:
[
defaultEthnicityFeature?.histogram?.min ?? defaultEthnicityFeature?.min ?? 0,
defaultEthnicityFeature?.histogram?.max ?? defaultEthnicityFeature?.max ?? 100,
],
};
}
if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
const poiFilterName = name as PoiFilterName;
const defaultPoiFeatureName = getDefaultPoiFilterFeatureName(features, poiFilterName);
const defaultPoiFeature = defaultPoiFeatureName
? features.find((feature) => feature.name === defaultPoiFeatureName)
: undefined;
if (!defaultPoiFeatureName) return prev;
return {
...prev,
[createPoiFilterKey(
poiFilterName,
defaultPoiFeatureName,
poiDistanceFilterIdRef.current++
)]: [
defaultPoiFeature?.histogram?.min ?? defaultPoiFeature?.min ?? 0,
defaultPoiFeature?.histogram?.max ?? defaultPoiFeature?.max ?? 5,
],
};
}
if (!meta) return prev; if (!meta) return prev;
if (meta.type === 'enum' && meta.values) { if (meta.type === 'enum' && meta.values) {
return { ...prev, [name]: [...meta.values!] }; return { ...prev, [name]: [...meta.values!] };
@ -234,6 +309,40 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
if (replaced) return normalizeFilters(next); if (replaced) return normalizeFilters(next);
} }
const ethnicityKeyId = getEthnicityFilterKeyId(name);
if (ethnicityKeyId != null) {
let replaced = false;
const next: FeatureFilters = {};
for (const [existingName, existingValue] of Object.entries(prev)) {
if (getEthnicityFilterKeyId(existingName) === ethnicityKeyId) {
if (!replaced) {
next[name] = value;
replaced = true;
}
continue;
}
next[existingName] = existingValue;
}
if (replaced) return normalizeFilters(next);
}
const poiDistanceKeyId = getPoiDistanceFilterKeyId(name);
if (poiDistanceKeyId != null) {
let replaced = false;
const next: FeatureFilters = {};
for (const [existingName, existingValue] of Object.entries(prev)) {
if (getPoiDistanceFilterKeyId(existingName) === poiDistanceKeyId) {
if (!replaced) {
next[name] = value;
replaced = true;
}
continue;
}
next[existingName] = existingValue;
}
if (replaced) return normalizeFilters(next);
}
return normalizeFilters({ ...prev, [name]: value }); return normalizeFilters({ ...prev, [name]: value });
}); });
}, []); }, []);

View file

@ -18,6 +18,8 @@ import {
} from '../lib/api'; } from '../lib/api';
import { getSchoolBackendFeatureName } from '../lib/school-filter'; import { getSchoolBackendFeatureName } from '../lib/school-filter';
import { getSpecificCrimeFeatureName } from '../lib/crime-filter'; import { getSpecificCrimeFeatureName } from '../lib/crime-filter';
import { getEthnicityFeatureName } from '../lib/ethnicity-filter';
import { getPoiDistanceFeatureName } from '../lib/poi-distance-filter';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts'; import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts'; import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
import { type TravelTimeEntry } from './useTravelTime'; import { type TravelTimeEntry } from './useTravelTime';
@ -86,7 +88,11 @@ export function useMapData({
const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD; const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD;
const getBackendFeatureName = useCallback( const getBackendFeatureName = useCallback(
(name: string) => (name: string) =>
getSchoolBackendFeatureName(name) ?? getSpecificCrimeFeatureName(name) ?? name, getSchoolBackendFeatureName(name) ??
getSpecificCrimeFeatureName(name) ??
getEthnicityFeatureName(name) ??
getPoiDistanceFeatureName(name) ??
name,
[] []
); );
const dataViewFeature = useMemo( const dataViewFeature = useMemo(
@ -279,9 +285,11 @@ export function useMapData({
useEffect(() => { useEffect(() => {
if (!bounds) { if (!bounds) {
latestDataRequestKeyRef.current = ''; latestDataRequestKeyRef.current = '';
setLoading(false);
return; return;
} }
latestDataRequestKeyRef.current = dataRequestKey; latestDataRequestKeyRef.current = dataRequestKey;
setLoading(true);
if (debounceRef.current) { if (debounceRef.current) {
clearTimeout(debounceRef.current); clearTimeout(debounceRef.current);
@ -294,7 +302,6 @@ export function useMapData({
abortControllerRef.current = new AbortController(); abortControllerRef.current = new AbortController();
const requestKey = dataRequestKey; const requestKey = dataRequestKey;
setLoading(true);
try { try {
if (usePostcodeView) { if (usePostcodeView) {
const params = new URLSearchParams({ bounds: boundsParam }); const params = new URLSearchParams({ bounds: boundsParam });

View file

@ -1,6 +1,6 @@
import { useState, useCallback, useMemo } from 'react'; import { useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { Step, CallBackProps } from 'react-joyride'; import type { EventData, Step } from 'react-joyride';
const STORAGE_KEY = 'tutorial_completed'; const STORAGE_KEY = 'tutorial_completed';
const JOYRIDE_ACTION_CLOSE = 'close'; const JOYRIDE_ACTION_CLOSE = 'close';
@ -18,43 +18,35 @@ export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked
title: t('tutorial.step1Title'), title: t('tutorial.step1Title'),
content: t('tutorial.step1Content'), content: t('tutorial.step1Content'),
placement: 'right' as const, placement: 'right' as const,
disableBeacon: true, skipBeacon: true,
}, },
{ {
target: '[data-tutorial="ai-filters"]', target: '[data-tutorial="ai-filters"]',
title: t('tutorial.step2Title'), title: t('tutorial.step2Title'),
content: t('tutorial.step2Content'), content: t('tutorial.step2Content'),
placement: 'right' as const, placement: 'right' as const,
disableBeacon: true, skipBeacon: true,
}, },
{ {
target: '[data-tutorial="map"]', target: '[data-tutorial="map-anchor"]',
title: t('tutorial.step3Title'), title: t('tutorial.step3Title'),
content: t('tutorial.step3Content'), content: t('tutorial.step3Content'),
placement: 'bottom' as const, placement: 'top' as const,
disableBeacon: true, skipBeacon: true,
}, },
{ {
target: '[data-tutorial="search"]', target: '[data-tutorial="search"]',
title: t('tutorial.step4Title'), title: t('tutorial.step4Title'),
content: t('tutorial.step4Content'), content: t('tutorial.step4Content'),
placement: 'bottom' as const, placement: 'bottom' as const,
disableBeacon: true, skipBeacon: true,
},
{
target: '[data-tutorial="right-pane"]',
title: t('tutorial.step5Title'),
content: t('tutorial.step5Content'),
placement: 'left' as const,
disableBeacon: true,
}, },
{ {
target: '[data-tutorial="poi-button"]', target: '[data-tutorial="poi-button"]',
title: t('tutorial.step6Title'), title: t('tutorial.step6Title'),
content: t('tutorial.step6Content'), content: t('tutorial.step6Content'),
placement: 'left' as const, placement: 'left' as const,
disableBeacon: true, skipBeacon: true,
styles: { tooltip: { transform: 'translateY(-50px)' } },
}, },
], ],
[t] [t]
@ -67,7 +59,7 @@ export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked
const shouldRun = run && !initialLoading && !isMobile && !blocked; const shouldRun = run && !initialLoading && !isMobile && !blocked;
const handleCallback = useCallback((data: CallBackProps) => { const handleCallback = useCallback((data: EventData) => {
const { status, action, type } = data; const { status, action, type } = data;
if (status === JOYRIDE_STATUS_FINISHED || status === JOYRIDE_STATUS_SKIPPED) { if (status === JOYRIDE_STATUS_FINISHED || status === JOYRIDE_STATUS_SKIPPED) {

View file

@ -12,11 +12,11 @@ export const details: Record<string, Record<string, string>> = {
'Last known price': 'Last known price':
"Le dernier prix de vente enregistré pour ce bien provenant des données HM Land Registry Price Paid. Couvre les ventes résidentielles en Angleterre. Peut dater de plusieurs années si le bien n'a pas été vendu récemment.", "Le dernier prix de vente enregistré pour ce bien provenant des données HM Land Registry Price Paid. Couvre les ventes résidentielles en Angleterre. Peut dater de plusieurs années si le bien n'a pas été vendu récemment.",
'Estimated current price': 'Estimated current price':
"Basé sur le dernier prix de vente, ajusté en fonction des évolutions locales des prix au fil du temps à l'aide d'un indice de ventes répétées (suivi par secteur de code postal et type de bien). Si des améliorations postérieures à la vente sont détectées d'après les relevés EPC, une prime de rénovation est ajoutée. Les ventes récentes seront proches du prix d'origine ; les ventes plus anciennes font l'objet d'un ajustement plus important.", "Basé sur le dernier prix de vente, les mouvements locaux des prix de revente et les biens vendus récemment à proximité. L'indice de ventes répétées est suivi par secteur de code postal et type de bien, avec lissage et combinaison avec les voisins lorsque les données sont limitées. Les ventes récentes restent proches du prix enregistré ; les ventes plus anciennes dépendent davantage du modèle.",
'Price per sqm': 'Price per sqm':
'Calculé en divisant le dernier prix de vente connu par la surface habitable totale indiquée dans le certificat EPC. Utile pour comparer la valeur entre des biens de tailles différentes. Disponible uniquement lorsque les données de prix et de surface existent toutes les deux.', 'Calculé en divisant le dernier prix de vente connu par la surface habitable totale indiquée dans le certificat EPC. Utile pour comparer la valeur entre des biens de tailles différentes. Disponible uniquement lorsque les données de prix et de surface existent toutes les deux.',
'Est. price per sqm': 'Est. price per sqm':
"Calculé en divisant le prix actuel estimé et ajusté à l'inflation (y compris toute prime de rénovation) par la surface habitable totale indiquée dans le certificat EPC. Fournit une comparaison prix/superficie plus actualisée que le prix au sqm basé sur le prix de vente historique.", 'Calculé en divisant le prix actuel estimé par le modèle par la surface habitable totale indiquée dans le certificat EPC. Fournit une comparaison prix/superficie plus actualisée que le prix au sqm basé sur le prix de vente historique.',
'Estimated monthly rent': 'Estimated monthly rent':
"Prix moyen mensuel de location provenant de l'indice des loyers privés de l'ONS (PIPR), correspondant à l'autorité locale et au nombre de chambres.", "Prix moyen mensuel de location provenant de l'indice des loyers privés de l'ONS (PIPR), correspondant à l'autorité locale et au nombre de chambres.",
'Total floor area (sqm)': 'Total floor area (sqm)':
@ -54,17 +54,17 @@ export const details: Record<string, Record<string, string>> = {
'Outstanding secondary schools within 5km': 'Outstanding secondary schools within 5km':
"Lycées et collèges financés par l'État dans un rayon de 5km ayant une note Ofsted actuelle d'Exceptionnel. Les établissements n'ayant pas encore été inspectés sont exclus.", "Lycées et collèges financés par l'État dans un rayon de 5km ayant une note Ofsted actuelle d'Exceptionnel. Les établissements n'ayant pas encore été inspectés sont exclus.",
'Education, Skills and Training Score': 'Education, Skills and Training Score':
"Provient des Indices de Déprivation anglais (inversé afin que plus le score est élevé, meilleur est le résultat). Couvre les résultats scolaires, l'accès à l'enseignement supérieur, les qualifications des adultes et la maîtrise de la langue anglaise. Des scores plus élevés indiquent moins de déprivation.", "Provient des Indices de Déprivation anglais, converti en percentile national où 0 % indique les secteurs les plus défavorisés et 100 % les moins défavorisés. Couvre les résultats scolaires, l'accès à l'enseignement supérieur, les qualifications des adultes et la maîtrise de la langue anglaise.",
'Income Score': 'Income Score':
"Provient des Indices de Déprivation anglais (inversé afin que plus le score est élevé, meilleur est le résultat). Des valeurs plus élevées indiquent moins de déprivation de revenus. Basé sur les allocations de soutien au revenu, l'allocation de demandeur d'emploi sous condition de ressources, l'allocation d'emploi et de soutien sous condition de ressources, le crédit de retraite, le crédit d'impôt pour le travail et les enfants, l'Universal Credit et les demandeurs d'asile.", "Provient des Indices de Déprivation anglais, converti en percentile national où 0 % indique la plus forte déprivation de revenus et 100 % la plus faible. Basé sur les allocations de soutien au revenu, l'allocation de demandeur d'emploi sous condition de ressources, l'allocation d'emploi et de soutien sous condition de ressources, le crédit de retraite, le crédit d'impôt pour le travail et les enfants, l'Universal Credit et les demandeurs d'asile.",
'Employment Score': 'Employment Score':
"Provient des Indices de Déprivation anglais (inversé afin que plus le score est élevé, meilleur est le résultat). Des valeurs plus élevées indiquent moins de déprivation d'emploi. Basé sur les allocataires de l'allocation de demandeur d'emploi, de l'allocation d'emploi et de soutien, de l'allocation d'incapacité, de l'allocation de handicap sévère, de l'allocation d'aidant et les bénéficiaires pertinents de l'Universal Credit.", "Provient des Indices de Déprivation anglais, converti en percentile national où 0 % indique la plus forte déprivation d'emploi et 100 % la plus faible. Basé sur les allocataires de l'allocation de demandeur d'emploi, de l'allocation d'emploi et de soutien, de l'allocation d'incapacité, de l'allocation de handicap sévère, de l'allocation d'aidant et les bénéficiaires pertinents de l'Universal Credit.",
'Health Deprivation and Disability Score': 'Health Deprivation and Disability Score':
"Provient des Indices de Déprivation anglais (inversé afin que plus le score est élevé, meilleur est le résultat). Des scores plus élevés indiquent un risque de décès prématuré plus faible et une meilleure qualité de vie. Dérivé des années de vie potentielle perdues, du ratio comparatif de maladie et d'invalidité, de la morbidité aiguë et des troubles de l'humeur et d'anxiété.", "Provient des Indices de Déprivation anglais, converti en percentile national où 0 % indique la plus forte déprivation de santé et 100 % la plus faible. Dérivé des années de vie potentielle perdues, du ratio comparatif de maladie et d'invalidité, de la morbidité aiguë et des troubles de l'humeur et d'anxiété.",
'Housing Conditions Score': 'Housing Conditions Score':
'Provient des Indices de Déprivation anglais, domaine Environnement de Vie (inversé afin que plus le score est élevé, meilleur est le résultat). Mesure la qualité du parc immobilier : disponibilité du chauffage central, état des logements et normes Decent Homes. Des scores plus élevés indiquent de meilleures conditions de logement.', 'Provient du domaine Environnement de vie des Indices de Déprivation anglais, converti en percentile national où 0 % indique les pires conditions et 100 % les meilleures. Mesure la qualité du parc immobilier : disponibilité du chauffage central, état des logements et normes Decent Homes.',
'Air Quality and Road Safety Score': 'Air Quality and Road Safety Score':
"Provient des Indices de Déprivation anglais, domaine Environnement de Vie (inversé afin que plus le score est élevé, meilleur est le résultat). Mesure la qualité de l'environnement de vie extérieur à travers des indicateurs de qualité de l'air et les victimes d'accidents de la route impliquant des piétons et des cyclistes. Des scores plus élevés indiquent de meilleurs environnements extérieurs.", "Provient du domaine Environnement de vie des Indices de Déprivation anglais, converti en percentile national où 0 % indique les pires conditions et 100 % les meilleures. Mesure l'environnement extérieur via la qualité de l'air et les victimes d'accidents de la route impliquant des piétons et des cyclistes.",
'Serious crime per 1k residents (avg/yr)': 'Serious crime per 1k residents (avg/yr)':
"Violences, braquages, cambriolages et possession d'armes pour 1 000 résidents habituels par an dans le LSOA. Utilise les données de criminalité au niveau de la rue de police.uk (2023-2025) et les décomptes de population du Census 2021. Normalise en fonction de la densité de population afin que les zones soient comparables quelle que soit leur taille.", "Violences, braquages, cambriolages et possession d'armes pour 1 000 résidents habituels par an dans le LSOA. Utilise les données de criminalité au niveau de la rue de police.uk (2023-2025) et les décomptes de population du Census 2021. Normalise en fonction de la densité de population afin que les zones soient comparables quelle que soit leur taille.",
'Minor crime per 1k residents (avg/yr)': 'Minor crime per 1k residents (avg/yr)':
@ -150,11 +150,11 @@ export const details: Record<string, Record<string, string>> = {
'Last known price': 'Last known price':
'Der zuletzt erfasste Verkaufspreis für diese Immobilie aus den HM Land Registry Price Paid-Daten. Umfasst Wohnimmobilienverkäufe in England. Kann Jahre alt sein, wenn die Immobilie nicht kürzlich verkauft wurde.', 'Der zuletzt erfasste Verkaufspreis für diese Immobilie aus den HM Land Registry Price Paid-Daten. Umfasst Wohnimmobilienverkäufe in England. Kann Jahre alt sein, wenn die Immobilie nicht kürzlich verkauft wurde.',
'Estimated current price': 'Estimated current price':
'Basiert auf dem letzten Verkaufspreis, angepasst an lokale Preisveränderungen im Laufe der Zeit mithilfe eines Repeat-Sales-Index (erfasst pro Postleitzahlensektor und Immobilientyp). Wenn nach dem Verkauf durchgeführte Renovierungen aus EPC-Aufzeichnungen erkennbar sind, wird ein Renovierungsaufschlag hinzugefügt. Kürzliche Verkäufe liegen nahe am ursprünglichen Preis; ältere Verkäufe werden stärker angepasst.', 'Basiert auf dem letzten Verkaufspreis, lokalen Preisbewegungen aus Wiederverkäufen und nahegelegenen kürzlich verkauften Immobilien. Der Repeat-Sales-Index wird nach Postleitzahlensektor und Immobilientyp verfolgt, mit Glättung und Nachbarschafts-Blending bei begrenzten Daten. Kürzliche Verkäufe bleiben nahe am erfassten Preis; ältere Verkäufe hängen stärker vom Modell ab.',
'Price per sqm': 'Price per sqm':
'Berechnet durch Division des zuletzt bekannten Verkaufspreises durch die Gesamtnutzfläche aus dem EPC-Zertifikat. Nützlich zum Vergleich des Wertes verschiedener Immobiliengrößen. Nur verfügbar, wenn sowohl Preis- als auch Flächendaten vorhanden sind.', 'Berechnet durch Division des zuletzt bekannten Verkaufspreises durch die Gesamtnutzfläche aus dem EPC-Zertifikat. Nützlich zum Vergleich des Wertes verschiedener Immobiliengrößen. Nur verfügbar, wenn sowohl Preis- als auch Flächendaten vorhanden sind.',
'Est. price per sqm': 'Est. price per sqm':
'Berechnet durch Division des inflationsbereinigten geschätzten aktuellen Preises (einschließlich etwaiger Renovierungsaufschläge) durch die Gesamtnutzfläche aus dem EPC-Zertifikat. Bietet einen aktuelleren Preis-pro-Fläche-Vergleich als der historische Verkaufspreis pro sqm.', 'Berechnet durch Division des modellierten geschätzten aktuellen Preises durch die Gesamtnutzfläche aus dem EPC-Zertifikat. Bietet einen aktuelleren Preis-pro-Fläche-Vergleich als der historische Verkaufspreis pro sqm.',
'Estimated monthly rent': 'Estimated monthly rent':
'Durchschnittlicher monatlicher Mietpreis aus dem ONS Price Index of Private Rents (PIPR), abgeglichen nach Gemeinde und Zimmeranzahl.', 'Durchschnittlicher monatlicher Mietpreis aus dem ONS Price Index of Private Rents (PIPR), abgeglichen nach Gemeinde und Zimmeranzahl.',
'Total floor area (sqm)': 'Total floor area (sqm)':
@ -192,17 +192,17 @@ export const details: Record<string, Record<string, string>> = {
'Outstanding secondary schools within 5km': 'Outstanding secondary schools within 5km':
'Staatlich geförderte weiterführende Schulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.', 'Staatlich geförderte weiterführende Schulen innerhalb von 5 km mit einer aktuellen Ofsted-Bewertung von „Outstanding". Noch nicht inspizierte Schulen sind ausgeschlossen.',
'Education, Skills and Training Score': 'Education, Skills and Training Score':
'Aus den englischen Deprivationsindizes (invertiert, sodass höher = besser bedeutet). Umfasst Schulleistungen, Hochschulzugang, Qualifikationen Erwachsener und Englischsprachkenntnisse. Höhere Werte weisen auf geringere Benachteiligung hin.', 'Aus den englischen Deprivationsindizes, in ein nationales Perzentil umgerechnet: 0 % steht für die am stärksten benachteiligten Gebiete, 100 % für die am wenigsten benachteiligten. Umfasst Schulleistungen, Hochschulzugang, Qualifikationen Erwachsener und Englischsprachkenntnisse.',
'Income Score': 'Income Score':
"Aus den englischen Deprivationsindizes (invertiert, sodass höher = besser bedeutet). Höhere Werte weisen auf geringere Einkommensbenachteiligung hin. Basiert auf Income Support, einkommensbasiertem Jobseeker's Allowance, einkommensbasiertem Employment and Support Allowance, Pension Credit, Working Tax Credit und Child Tax Credit, Universal Credit sowie Asylbewerbern.", "Aus den englischen Deprivationsindizes, in ein nationales Perzentil umgerechnet: 0 % steht für die höchste und 100 % für die niedrigste Einkommensbenachteiligung. Basiert auf Income Support, einkommensbasiertem Jobseeker's Allowance, einkommensbasiertem Employment and Support Allowance, Pension Credit, Working Tax Credit und Child Tax Credit, Universal Credit sowie Asylbewerbern.",
'Employment Score': 'Employment Score':
"Aus den englischen Deprivationsindizes (invertiert, sodass höher = besser bedeutet). Höhere Werte weisen auf geringere Beschäftigungsbenachteiligung hin. Basiert auf Empfängern von Jobseeker's Allowance, Employment and Support Allowance, Incapacity Benefit, Severe Disablement Allowance, Carer's Allowance und relevanten Universal Credit-Empfängern.", "Aus den englischen Deprivationsindizes, in ein nationales Perzentil umgerechnet: 0 % steht für die höchste und 100 % für die niedrigste Beschäftigungsbenachteiligung. Basiert auf Empfängern von Jobseeker's Allowance, Employment and Support Allowance, Incapacity Benefit, Severe Disablement Allowance, Carer's Allowance und relevanten Universal Credit-Empfängern.",
'Health Deprivation and Disability Score': 'Health Deprivation and Disability Score':
'Aus den englischen Deprivationsindizes (invertiert, sodass höher = besser bedeutet). Höhere Werte weisen auf ein geringeres Risiko eines vorzeitigen Todes und eine bessere Lebensqualität hin. Abgeleitet aus verlorenen Lebensjahren, vergleichender Krankheits- und Behinderungsquote, akuter Morbidität sowie Stimmungs- und Angststörungen.', 'Aus den englischen Deprivationsindizes, in ein nationales Perzentil umgerechnet: 0 % steht für die höchste und 100 % für die niedrigste Gesundheitsbenachteiligung. Abgeleitet aus verlorenen Lebensjahren, vergleichender Krankheits- und Behinderungsquote, akuter Morbidität sowie Stimmungs- und Angststörungen.',
'Housing Conditions Score': 'Housing Conditions Score':
'Aus den englischen Deprivationsindizes, Bereich Wohnumgebung (invertiert, sodass höher = besser bedeutet). Misst die Qualität des Wohnungsbestands: Verfügbarkeit von Zentralheizung, Wohnungszustand und Decent Homes-Standards. Höhere Werte weisen auf bessere Wohnbedingungen hin.', 'Aus dem Bereich Wohnumgebung der englischen Deprivationsindizes, in ein nationales Perzentil umgerechnet: 0 % steht für die schlechtesten und 100 % für die besten Bedingungen. Misst die Qualität des Wohnungsbestands: Verfügbarkeit von Zentralheizung, Wohnungszustand und Decent Homes-Standards.',
'Air Quality and Road Safety Score': 'Air Quality and Road Safety Score':
'Aus den englischen Deprivationsindizes, Bereich Wohnumgebung (invertiert, sodass höher = besser bedeutet). Misst die Qualität der Außenwohnumgebung anhand von Luftqualitätsindikatoren und Straßenverkehrsunfällen mit Fußgängern und Radfahrern. Höhere Werte weisen auf bessere Außenumgebungen hin.', 'Aus dem Bereich Wohnumgebung der englischen Deprivationsindizes, in ein nationales Perzentil umgerechnet: 0 % steht für die schlechtesten und 100 % für die besten Bedingungen. Misst die Außenwohnumgebung anhand von Luftqualitätsindikatoren und Straßenverkehrsunfällen mit Fußgängern und Radfahrern.',
'Serious crime per 1k residents (avg/yr)': 'Serious crime per 1k residents (avg/yr)':
'Gewalt, Raub, Einbruch und Waffenbesitz pro 1.000 Einwohner pro Jahr im LSOA. Verwendet police.uk-Kriminalitätsdaten auf Straßenebene (20232025) und Census 2021-Bevölkerungszahlen. Normalisiert nach Bevölkerungsdichte, sodass Gebiete unabhängig von ihrer Größe vergleichbar sind.', 'Gewalt, Raub, Einbruch und Waffenbesitz pro 1.000 Einwohner pro Jahr im LSOA. Verwendet police.uk-Kriminalitätsdaten auf Straßenebene (20232025) und Census 2021-Bevölkerungszahlen. Normalisiert nach Bevölkerungsdichte, sodass Gebiete unabhängig von ihrer Größe vergleichbar sind.',
'Minor crime per 1k residents (avg/yr)': 'Minor crime per 1k residents (avg/yr)':
@ -288,11 +288,11 @@ export const details: Record<string, Record<string, string>> = {
'Last known price': 'Last known price':
'来自英国土地注册局价格数据中该房产最近一次记录的成交价格。涵盖英格兰地区的住宅销售。若该房产近期未出售,数据可能已有数年之久。', '来自英国土地注册局价格数据中该房产最近一次记录的成交价格。涵盖英格兰地区的住宅销售。若该房产近期未出售,数据可能已有数年之久。',
'Estimated current price': 'Estimated current price':
'基于最后一次成交价格使用重复销售指数按邮政编码区段和房产类型追踪调整当地房价随时间的变化。若EPC记录显示售后有改造记录则会增加装修溢价。近期销售与原价接近较早的销售调整幅度更大。', '基于最后一次成交价格、本地重复销售价格走势,以及附近近期成交房产。重复销售指数按邮政编码区段和房产类型追踪;在数据较少时会进行平滑并结合邻近成交样本。近期成交会接近记录价格;较早成交更依赖模型。',
'Price per sqm': 'Price per sqm':
'用最后已知成交价除以EPC证书中的总建筑面积计算得出。便于比较不同面积房产的价值。仅在价格和面积数据均存在时才可用。', '用最后已知成交价除以EPC证书中的总建筑面积计算得出。便于比较不同面积房产的价值。仅在价格和面积数据均存在时才可用。',
'Est. price per sqm': 'Est. price per sqm':
'用经通胀调整的估算当前价格(含装修溢价)除以EPC证书中的总建筑面积计算得出。与历史成交价格每平方米相比提供更为最新的单位面积价格对比。', '用模型估算的当前价格除以EPC证书中的总建筑面积计算得出。与历史成交价格每平方米相比提供更为最新的单位面积价格对比。',
'Estimated monthly rent': 'Estimated monthly rent':
'来自ONS私人租赁价格指数PIPR的平均月租金按地方政府和卧室数量匹配。', '来自ONS私人租赁价格指数PIPR的平均月租金按地方政府和卧室数量匹配。',
'Total floor area (sqm)': 'Total floor area (sqm)':
@ -330,17 +330,17 @@ export const details: Record<string, Record<string, string>> = {
'Outstanding secondary schools within 5km': 'Outstanding secondary schools within 5km':
'5km范围内Ofsted评级为"Outstanding"的公立中学数量。尚未接受评估的学校不计入。', '5km范围内Ofsted评级为"Outstanding"的公立中学数量。尚未接受评估的学校不计入。',
'Education, Skills and Training Score': 'Education, Skills and Training Score':
'来自英格兰剥夺指数(取反后越高越好)。涵盖学校成绩、高等教育入学率、成人学历和英语水平。分数越高表示剥夺程度越低。', '来自英格兰剥夺指数转换为全国百分位0%表示最贫困100%表示最不贫困。涵盖学校成绩、高等教育入学率、成人学历和英语水平。',
'Income Score': 'Income Score':
'来自英格兰剥夺指数(取反后越高越好)。数值越高表示收入剥夺程度越低。基于收入支持、基于收入的求职者津贴、基于收入的就业与支持津贴、养老金补贴、工作税收抵免和子女税收抵免、普惠信用以及寻求庇护者等数据。', '来自英格兰剥夺指数转换为全国百分位0%表示收入剥夺最严重100%表示收入剥夺最轻。基于收入支持、基于收入的求职者津贴、基于收入的就业与支持津贴、养老金补贴、工作税收抵免和子女税收抵免、普惠信用以及寻求庇护者等数据。',
'Employment Score': 'Employment Score':
'来自英格兰剥夺指数(取反后越高越好)。数值越高表示就业剥夺程度越低。基于求职者津贴、就业与支持津贴、丧失劳动能力津贴、严重残疾津贴、护理者津贴申领者及相关普惠信用申领者等数据。', '来自英格兰剥夺指数转换为全国百分位0%表示就业剥夺最严重100%表示就业剥夺最轻。基于求职者津贴、就业与支持津贴、丧失劳动能力津贴、严重残疾津贴、护理者津贴申领者及相关普惠信用申领者等数据。',
'Health Deprivation and Disability Score': 'Health Deprivation and Disability Score':
'来自英格兰剥夺指数(取反后越高越好)。分数越高表示过早死亡风险越低、生活质量越好。来源于潜在寿命损失年、比较疾病和残疾率、急性发病率以及情绪和焦虑障碍等指标。', '来自英格兰剥夺指数转换为全国百分位0%表示健康剥夺最严重100%表示健康剥夺最轻。来源于潜在寿命损失年、比较疾病和残疾率、急性发病率以及情绪和焦虑障碍等指标。',
'Housing Conditions Score': 'Housing Conditions Score':
'来自英格兰剥夺指数的居住环境领域(取反后越高越好)。衡量住房存量质量中央供暖覆盖率、住房状况以及Decent Homes标准。分数越高表示住房条件越好。', '来自英格兰剥夺指数的居住环境领域转换为全国百分位0%表示条件最差100%表示条件最好。衡量住房存量质量中央供暖覆盖率、住房状况以及Decent Homes标准。',
'Air Quality and Road Safety Score': 'Air Quality and Road Safety Score':
'来自英格兰剥夺指数的居住环境领域(取反后越高越好)。通过空气质量指标以及涉及行人和骑行者的道路交通事故伤亡人数衡量室外生活环境质量。分数越高表示室外环境越好。', '来自英格兰剥夺指数的居住环境领域转换为全国百分位0%表示条件最差100%表示条件最好。通过空气质量指标以及涉及行人和骑行者的道路交通事故伤亡人数衡量室外生活环境质量。',
'Serious crime per 1k residents (avg/yr)': 'Serious crime per 1k residents (avg/yr)':
'LSOA内每1,000名常住居民每年发生的暴力、抢劫、入室盗窃和持有武器犯罪数量。使用police.uk街道级犯罪数据2023-2025年和Census 2021人口数据。按人口密度标准化便于不同规模地区之间的比较。', 'LSOA内每1,000名常住居民每年发生的暴力、抢劫、入室盗窃和持有武器犯罪数量。使用police.uk街道级犯罪数据2023-2025年和Census 2021人口数据。按人口密度标准化便于不同规模地区之间的比较。',
'Minor crime per 1k residents (avg/yr)': 'Minor crime per 1k residents (avg/yr)':
@ -420,11 +420,11 @@ export const details: Record<string, Record<string, string>> = {
'Last known price': 'Last known price':
'इस संपत्ति की अंतिम दर्ज बिक्री कीमत HM Land Registry Price Paid डेटा से आती है. यह England की आवासीय बिक्री को कवर करती है. अगर संपत्ति हाल में नहीं बिकी है तो यह कीमत कई साल पुरानी हो सकती है.', 'इस संपत्ति की अंतिम दर्ज बिक्री कीमत HM Land Registry Price Paid डेटा से आती है. यह England की आवासीय बिक्री को कवर करती है. अगर संपत्ति हाल में नहीं बिकी है तो यह कीमत कई साल पुरानी हो सकती है.',
'Estimated current price': 'Estimated current price':
'अंतिम बिक्री कीमत से शुरू करके स्थानीय कीमत बदलावों के अनुसार repeat-sales index से समायोजित किया गया है (postcode sector और property type के अनुसार ट्रैक किया गया). अगर EPC रिकॉर्ड से बिक्री के बाद नवीनीकरण दिखता है, तो नवीनीकरण प्रीमियम जोड़ा जाता है. हाल की बिक्री मूल कीमत के करीब रहेगी; पुरानी बिक्री में ज्यादा समायोजन होगा.', 'अंतिम बिक्री कीमत, स्थानीय repeat-sales कीमत बदलाव और आसपास हाल में बिकी संपत्तियों पर आधारित. Repeat-sales index को postcode sector और property type के अनुसार ट्रैक किया जाता है, और कम डेटा होने पर smoothing व nearby-sale blending इस्तेमाल होती है. हाल की बिक्री दर्ज कीमत के करीब रहती है; पुरानी बिक्री में मॉडल पर निर्भरता ज्यादा होती है.',
'Price per sqm': 'Price per sqm':
'अंतिम ज्ञात बिक्री कीमत को EPC प्रमाणपत्र में दर्ज कुल फर्श क्षेत्र से भाग देकर निकाला गया. अलग-अलग आकार की संपत्तियों की मूल्य तुलना के लिए उपयोगी. केवल तब उपलब्ध जब कीमत और फर्श क्षेत्र, दोनों डेटा मौजूद हों.', 'अंतिम ज्ञात बिक्री कीमत को EPC प्रमाणपत्र में दर्ज कुल फर्श क्षेत्र से भाग देकर निकाला गया. अलग-अलग आकार की संपत्तियों की मूल्य तुलना के लिए उपयोगी. केवल तब उपलब्ध जब कीमत और फर्श क्षेत्र, दोनों डेटा मौजूद हों.',
'Est. price per sqm': 'Est. price per sqm':
'मुद्रास्फीति-समायोजित अनुमानित मौजूदा कीमत (किसी नवीनीकरण प्रीमियम सहित) को EPC प्रमाणपत्र में दर्ज कुल फर्श क्षेत्र से भाग देकर निकाला गया. ऐतिहासिक बिक्री कीमत पर आधारित प्रति वर्ग मी कीमत की तुलना में ज्यादा ताजा कीमत/क्षेत्र तुलना देता है.', 'मॉडल से अनुमानित मौजूदा कीमत को EPC प्रमाणपत्र में दर्ज कुल फर्श क्षेत्र से भाग देकर निकाला गया. ऐतिहासिक बिक्री कीमत पर आधारित प्रति वर्ग मी कीमत की तुलना में ज्यादा ताजा कीमत/क्षेत्र तुलना देता है.',
'Estimated monthly rent': 'Estimated monthly rent':
'ONS Price Index of Private Rents (PIPR) से औसत मासिक किराया, जिसे स्थानीय प्राधिकरण और बेडरूम संख्या से मिलाया गया है.', 'ONS Price Index of Private Rents (PIPR) से औसत मासिक किराया, जिसे स्थानीय प्राधिकरण और बेडरूम संख्या से मिलाया गया है.',
'Total floor area (sqm)': 'Total floor area (sqm)':
@ -462,17 +462,17 @@ export const details: Record<string, Record<string, string>> = {
'Outstanding secondary schools within 5km': 'Outstanding secondary schools within 5km':
'5km के भीतर state-funded secondary schools जिनकी current Ofsted rating Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.', '5km के भीतर state-funded secondary schools जिनकी current Ofsted rating Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
'Education, Skills and Training Score': 'Education, Skills and Training Score':
'English Indices of Deprivation से लिया गया (invert किया गया ताकि higher = better). School attainment, higher education entry, adult qualifications और English language proficiency को cover करता है. Higher scores कम deprivation दिखाते हैं.', 'English Indices of Deprivation से लिया गया और national percentile में बदला गया: 0% सबसे अधिक deprived, 100% सबसे कम deprived. School attainment, higher education entry, adult qualifications और English language proficiency को cover करता है.',
'Income Score': 'Income Score':
'English Indices of Deprivation से लिया गया (invert किया गया ताकि higher = better). Higher values कम income deprivation दिखाते हैं. Income support, income-based Jobseekers Allowance, income-based Employment and Support Allowance, Pension Credit, Working and Child Tax Credit, Universal Credit और asylum seekers पर आधारित.', 'English Indices of Deprivation से लिया गया और national percentile में बदला गया: 0% सबसे अधिक income deprived, 100% सबसे कम income deprived. Income support, income-based Jobseekers Allowance, income-based Employment and Support Allowance, Pension Credit, Working and Child Tax Credit, Universal Credit और asylum seekers पर आधारित.',
'Employment Score': 'Employment Score':
'English Indices of Deprivation से लिया गया (invert किया गया ताकि higher = better). Higher values कम employment deprivation दिखाते हैं. Jobseekers Allowance, Employment and Support Allowance, Incapacity Benefit, Severe Disablement Allowance, Carers Allowance और relevant Universal Credit claimants पर आधारित.', 'English Indices of Deprivation से लिया गया और national percentile में बदला गया: 0% सबसे अधिक employment deprived, 100% सबसे कम employment deprived. Jobseekers Allowance, Employment and Support Allowance, Incapacity Benefit, Severe Disablement Allowance, Carers Allowance और relevant Universal Credit claimants पर आधारित.',
'Health Deprivation and Disability Score': 'Health Deprivation and Disability Score':
'English Indices of Deprivation से लिया गया (invert किया गया ताकि higher = better). Higher scores premature death का कम risk और बेहतर quality of life दिखाते हैं. Years of potential life lost, comparative illness and disability ratio, acute morbidity और mood/anxiety disorders से derived.', 'English Indices of Deprivation से लिया गया और national percentile में बदला गया: 0% सबसे अधिक health deprived, 100% सबसे कम health deprived. Years of potential life lost, comparative illness and disability ratio, acute morbidity और mood/anxiety disorders से derived.',
'Housing Conditions Score': 'Housing Conditions Score':
'English Indices of Deprivation, Living Environment domain से लिया गया (invert किया गया ताकि higher = better). Housing stock की quality मापता है: central heating availability, housing condition और Decent Homes standards. Higher scores बेहतर housing conditions दिखाते हैं.', 'English Indices of Deprivation के Living Environment domain से लिया गया और national percentile में बदला गया: 0% सबसे खराब conditions, 100% सबसे अच्छी. Housing stock की quality मापता है: central heating availability, housing condition और Decent Homes standards.',
'Air Quality and Road Safety Score': 'Air Quality and Road Safety Score':
'English Indices of Deprivation, Living Environment domain से लिया गया (invert किया गया ताकि higher = better). Air quality indicators और pedestrians/cyclists से जुड़े road traffic accident casualties के जरिए outdoor living environment quality मापता है. Higher scores बेहतर outdoor environments दिखाते हैं.', 'English Indices of Deprivation के Living Environment domain से लिया गया और national percentile में बदला गया: 0% सबसे खराब conditions, 100% सबसे अच्छी. Air quality indicators और pedestrians/cyclists से जुड़े road traffic accident casualties के जरिए outdoor living environment quality मापता है.',
'Serious crime per 1k residents (avg/yr)': 'Serious crime per 1k residents (avg/yr)':
'LSOA में प्रति 1,000 usual residents प्रति वर्ष violence, robbery, burglary और possession of weapons. police.uk street-level crime data (2023-2025) और Census 2021 population counts का उपयोग करता है. Population density normalize करता है ताकि areas size की परवाह किए बिना comparable हों.', 'LSOA में प्रति 1,000 usual residents प्रति वर्ष violence, robbery, burglary और possession of weapons. police.uk street-level crime data (2023-2025) और Census 2021 population counts का उपयोग करता है. Population density normalize करता है ताकि areas size की परवाह किए बिना comparable हों.',
'Minor crime per 1k residents (avg/yr)': 'Minor crime per 1k residents (avg/yr)':
@ -558,11 +558,11 @@ export const details: Record<string, Record<string, string>> = {
'Last known price': 'Last known price':
'Az ingatlan utolsó rögzített adásvételi ára az HM Land Registry Price Paid adatokból. Az angliai lakóingatlan-értékesítésekre vonatkozik. Lehet, hogy évekkel ezelőtti adat, ha az ingatlan nem kelt el a közelmúltban.', 'Az ingatlan utolsó rögzített adásvételi ára az HM Land Registry Price Paid adatokból. Az angliai lakóingatlan-értékesítésekre vonatkozik. Lehet, hogy évekkel ezelőtti adat, ha az ingatlan nem kelt el a közelmúltban.',
'Estimated current price': 'Estimated current price':
'Az utolsó adásvételi áron alapul, amelyet az idő múlásával bekövetkezett helyi árváltozásokhoz igazítottak egy ismételt értékesítési index segítségével (irányítószám-szektor és ingatlan típusa szerint nyomon követve). Ha az EPC-adatokból az értékesítés utáni felújítás észlelhető, felújítási prémium kerül hozzáadásra. A közelmúltbeli adásvételek közel lesznek az eredeti árhoz; a régebbi adásvételeket jobban korrigálják.', 'Az utolsó adásvételi áron, a helyi ismételt értékesítési ármozgásokon és a közeli, nemrég eladott ingatlanokon alapul. Az ismételt értékesítési indexet irányítószám-szektor és ingatlantípus szerint követi, kevés adat esetén simítással és szomszédos eladásokkal kombinálva. A közelmúltbeli adásvételek közel maradnak a rögzített árhoz; a régebbiek jobban függnek a modelltől.',
'Price per sqm': 'Price per sqm':
'Az utolsó ismert adásvételi árat az EPC tanúsítványból származó teljes alapterülettel elosztva számítják ki. Hasznos a különböző méretű ingatlanok értékének összehasonlításához. Csak akkor elérhető, ha mind az ár, mind az alapterület adatai rendelkezésre állnak.', 'Az utolsó ismert adásvételi árat az EPC tanúsítványból származó teljes alapterülettel elosztva számítják ki. Hasznos a különböző méretű ingatlanok értékének összehasonlításához. Csak akkor elérhető, ha mind az ár, mind az alapterület adatai rendelkezésre állnak.',
'Est. price per sqm': 'Est. price per sqm':
'Az inflációval korrigált becsült aktuális árat (beleértve az esetleges felújítási prémiumot) az EPC tanúsítványból származó teljes alapterülettel elosztva számítják ki. Naprakészebb ár/terület összehasonlítást nyújt, mint a korábbi adásvételi ár per sqm.', 'A modellezett becsült aktuális árat az EPC tanúsítványból származó teljes alapterülettel elosztva számítják ki. Naprakészebb ár/terület összehasonlítást nyújt, mint a korábbi adásvételi ár per sqm.',
'Estimated monthly rent': 'Estimated monthly rent':
'Az ONS Price Index of Private Rents (PIPR) alapján számított átlagos havi bérleti díj, helyi hatóság és hálószobák száma szerint párosítva.', 'Az ONS Price Index of Private Rents (PIPR) alapján számított átlagos havi bérleti díj, helyi hatóság és hálószobák száma szerint párosítva.',
'Total floor area (sqm)': 'Total floor area (sqm)':
@ -600,17 +600,17 @@ export const details: Record<string, Record<string, string>> = {
'Outstanding secondary schools within 5km': 'Outstanding secondary schools within 5km':
'5 km-en belüli állami fenntartású középiskolák, amelyek aktuális Ofsted besorolása Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.', '5 km-en belüli állami fenntartású középiskolák, amelyek aktuális Ofsted besorolása Kiemelkedő. A még nem vizsgált iskolák ki vannak zárva.',
'Education, Skills and Training Score': 'Education, Skills and Training Score':
'Az Angol Nélkülözési Indexekből (megfordítva, így magasabb = jobb). Az iskolai teljesítményt, a felsőoktatásba való bejutást, a felnőttkori képesítéseket és az angol nyelvi jártasságot foglalja magában. A magasabb pontszámok kisebb mértékű nélkülözést jeleznek.', 'Az Angol Nélkülözési Indexekből származik, országos percentilissé alakítva: 0% a leginkább hátrányos, 100% a legkevésbé hátrányos területeket jelzi. Az iskolai teljesítményt, a felsőoktatásba való bejutást, a felnőttkori képesítéseket és az angol nyelvi jártasságot foglalja magában.',
'Income Score': 'Income Score':
'Az Angol Nélkülözési Indexekből (megfordítva, így magasabb = jobb). A magasabb értékek kisebb mértékű jövedelmi nélkülözést jeleznek. A jövedelempótló támogatás, jövedelemalapú Munkaügyi Segély, jövedelemalapú Foglalkoztatási és Támogatási Segély, Nyugdíjkiegészítés, Munkavállalói és Gyermekadókedvezmény, Univerzális Hitel és menedékkérők alapján.', 'Az Angol Nélkülözési Indexekből származik, országos percentilissé alakítva: 0% a legnagyobb, 100% a legkisebb jövedelmi deprivációt jelzi. A jövedelempótló támogatás, jövedelemalapú Munkaügyi Segély, jövedelemalapú Foglalkoztatási és Támogatási Segély, Nyugdíjkiegészítés, Munkavállalói és Gyermekadókedvezmény, Univerzális Hitel és menedékkérők alapján.',
'Employment Score': 'Employment Score':
'Az Angol Nélkülözési Indexekből (megfordítva, így magasabb = jobb). A magasabb értékek kisebb mértékű foglalkoztatási nélkülözést jeleznek. A Munkaügyi Segély, Foglalkoztatási és Támogatási Segély, Munkaképtelenségi Juttatás, Súlyos Rokkantsági Pótlék, Gondozói Juttatás igénylői és a vonatkozó Univerzális Hitel igénylői alapján.', 'Az Angol Nélkülözési Indexekből származik, országos percentilissé alakítva: 0% a legnagyobb, 100% a legkisebb foglalkoztatási deprivációt jelzi. A Munkaügyi Segély, Foglalkoztatási és Támogatási Segély, Munkaképtelenségi Juttatás, Súlyos Rokkantsági Pótlék, Gondozói Juttatás igénylői és a vonatkozó Univerzális Hitel igénylői alapján.',
'Health Deprivation and Disability Score': 'Health Deprivation and Disability Score':
'Az Angol Nélkülözési Indexekből (megfordítva, így magasabb = jobb). A magasabb pontszámok alacsonyabb korai halálozási kockázatot és jobb életminőséget jeleznek. Az elveszített potenciális életévekből, a komparatív betegségi és rokkantsági arányból, az akut morbiditásból, valamint a hangulati és szorongásos zavarokból vezethető le.', 'Az Angol Nélkülözési Indexekből származik, országos percentilissé alakítva: 0% a legnagyobb, 100% a legkisebb egészségügyi deprivációt jelzi. Az elveszített potenciális életévekből, a komparatív betegségi és rokkantsági arányból, az akut morbiditásból, valamint a hangulati és szorongásos zavarokból vezethető le.',
'Housing Conditions Score': 'Housing Conditions Score':
'Az Angol Nélkülözési Indexek Lakókörnyezet tartományából (megfordítva, így magasabb = jobb). A lakásállomány minőségét méri: gázfűtés rendelkezésre állása, lakásállapot és Decent Homes szabványok. A magasabb pontszámok jobb lakáskörülményeket jeleznek.', 'Az Angol Nélkülözési Indexek Lakókörnyezet tartományából származik, országos percentilissé alakítva: 0% a legrosszabb, 100% a legjobb körülményeket jelzi. A lakásállomány minőségét méri: gázfűtés rendelkezésre állása, lakásállapot és Decent Homes szabványok.',
'Air Quality and Road Safety Score': 'Air Quality and Road Safety Score':
'Az Angol Nélkülözési Indexek Lakókörnyezet tartományából (megfordítva, így magasabb = jobb). A külső lakókörnyezet minőségét méri a levegőminőségi mutatók és a gyalogosokat, kerékpárosokat érintő közúti közlekedési baleseti áldozatok alapján. A magasabb pontszámok jobb külső környezetet jeleznek.', 'Az Angol Nélkülözési Indexek Lakókörnyezet tartományából származik, országos percentilissé alakítva: 0% a legrosszabb, 100% a legjobb körülményeket jelzi. A külső lakókörnyezet minőségét méri a levegőminőségi mutatók és a gyalogosokat, kerékpárosokat érintő közúti közlekedési baleseti áldozatok alapján.',
'Serious crime per 1k residents (avg/yr)': 'Serious crime per 1k residents (avg/yr)':
'Erőszakos bűncselekmények, rablás, betörés és fegyverbirtoklás 1 000 szokásos lakóra vetítve évente az LSOA-ban. A police.uk utcai szintű bűnügyi adatait (20232025) és a Census 2021 népességszámait használja. Normalizálja a népsűrűséget, így a területek mérettől függetlenül összehasonlíthatók.', 'Erőszakos bűncselekmények, rablás, betörés és fegyverbirtoklás 1 000 szokásos lakóra vetítve évente az LSOA-ban. A police.uk utcai szintű bűnügyi adatait (20232025) és a Census 2021 népességszámait használja. Normalizálja a népsűrűséget, így a területek mérettől függetlenül összehasonlíthatók.',
'Minor crime per 1k residents (avg/yr)': 'Minor crime per 1k residents (avg/yr)':

View file

@ -66,6 +66,10 @@ h3 {
} }
.home-content-surface { .home-content-surface {
--home-hex-pattern: url("/home-hex-pattern.svg");
--home-pointer-active: 0;
--home-pointer-x: 50%;
--home-pointer-y: 50%;
isolation: isolate; isolation: isolate;
background: linear-gradient(180deg, #f3efe8 0%, #fafaf9 36%, #eef7f3 100%); background: linear-gradient(180deg, #f3efe8 0%, #fafaf9 36%, #eef7f3 100%);
} }
@ -82,17 +86,14 @@ h3 {
.home-content-surface::before { .home-content-surface::before {
content: ''; content: '';
position: absolute; position: absolute;
inset: -120vh -120vw; inset: 0;
z-index: 0; z-index: 0;
pointer-events: none; pointer-events: none;
background: background-image: var(--home-hex-pattern);
linear-gradient(rgba(20, 184, 166, 0.11) 1px, transparent 1px), background-position: center calc(var(--home-scroll-y, 0px) * 0.14);
linear-gradient(90deg, rgba(20, 184, 166, 0.11) 1px, transparent 1px); background-size: 138px 159px;
background-size: opacity: 0.14;
56px 56px, will-change: background-position;
56px 56px;
transform: skew(20deg, -15deg);
transform-origin: center;
} }
.home-content-surface::after { .home-content-surface::after {
@ -101,21 +102,52 @@ h3 {
inset: 0; inset: 0;
z-index: 1; z-index: 1;
pointer-events: none; pointer-events: none;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0)); background-image: var(--home-hex-pattern);
background-position: center calc(var(--home-scroll-y, 0px) * 0.14);
background-size: 138px 159px;
filter: drop-shadow(0 0 6px rgba(20, 184, 166, 0.3));
opacity: calc(var(--home-pointer-active, 0) * 0.58);
transition: opacity 0.16s ease;
-webkit-mask-image: radial-gradient(
circle 260px at var(--home-pointer-x) var(--home-pointer-y),
#000 0%,
rgba(0, 0, 0, 0.75) 36%,
transparent 72%
);
mask-image: radial-gradient(
circle 260px at var(--home-pointer-x) var(--home-pointer-y),
#000 0%,
rgba(0, 0, 0, 0.75) 36%,
transparent 72%
);
will-change: background-position, opacity;
} }
.dark .home-content-surface { .dark .home-content-surface {
--home-hex-pattern: url("/home-hex-pattern-dark.svg");
background: linear-gradient(180deg, #121827 0%, #0a0e1a 42%, #10211f 100%); background: linear-gradient(180deg, #121827 0%, #0a0e1a 42%, #10211f 100%);
} }
.dark .home-content-surface::before { .dark .home-content-surface::before {
background: opacity: 0.11;
linear-gradient(rgba(45, 212, 191, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(45, 212, 191, 0.1) 1px, transparent 1px);
} }
.dark .home-content-surface::after { .dark .home-content-surface::after {
background: linear-gradient(180deg, rgba(10, 14, 26, 0.22), rgba(10, 14, 26, 0)); filter: drop-shadow(0 0 7px rgba(45, 212, 191, 0.36));
opacity: calc(var(--home-pointer-active, 0) * 0.5);
}
@media (prefers-reduced-motion: reduce) {
.home-hero-hex-parallax {
transform: none;
will-change: auto;
}
.home-content-surface::before,
.home-content-surface::after {
background-position: center top;
will-change: auto;
}
} }
/* Fade-in animation for homepage sections */ /* Fade-in animation for homepage sections */
@ -175,12 +207,12 @@ h3 {
max-width: 42rem; max-width: 42rem;
} }
.home-hero-showcase { .product-showcase {
max-width: none; max-width: none;
justify-self: stretch; justify-self: stretch;
} }
.home-hero-showcase-frame { .product-showcase-frame {
height: 40rem; height: 40rem;
} }
} }
@ -195,12 +227,12 @@ h3 {
max-width: 45rem; max-width: 45rem;
} }
.home-hero-showcase-frame { .product-showcase-frame {
height: 40rem; height: 40rem;
} }
} }
@media (min-width: 1024px) and (min-height: 900px) { @media (min-width: 1200px) and (min-height: 900px) {
.hero-roomy-lift { .hero-roomy-lift {
transform: translateY(-2.5rem); transform: translateY(-2.5rem);
} }
@ -328,6 +360,10 @@ h3 {
background-color: rgba(28, 25, 23, 0.5); background-color: rgba(28, 25, 23, 0.5);
} }
.map-has-mobile-bottom-sheet .maplibregl-ctrl-bottom-left {
bottom: calc(var(--map-mobile-bottom-inset, 0px) + 0.25rem) !important;
}
/* Hide scrollbar for pill groups on mobile */ /* Hide scrollbar for pill groups on mobile */
.scrollbar-hide { .scrollbar-hide {
-ms-overflow-style: none; -ms-overflow-style: none;

View file

@ -14,6 +14,7 @@ describe('format utilities', () => {
expect(formatFilterValue(1250)).toBe('1.3k'); expect(formatFilterValue(1250)).toBe('1.3k');
expect(formatFilterValue(1_250_000)).toBe('1.3M'); expect(formatFilterValue(1_250_000)).toBe('1.3M');
expect(formatFilterValue(1250, true)).toBe('1250'); expect(formatFilterValue(1250, true)).toBe('1250');
expect(formatFilterValue(87.4, { raw: true, suffix: '%' })).toBe('87%');
expect(formatTransactionDate(2024.5)).toBe('Jul 2024'); expect(formatTransactionDate(2024.5)).toBe('Jul 2024');
}); });

View file

@ -17,14 +17,12 @@ export class ScreenshotCache {
/** /**
* Build a cache key by quantizing view params and hashing. * Build a cache key by quantizing view params and hashing.
* - lat/lon quantized to 2 decimal places * lat/lon are rounded to 2 decimals and zoom to an integer so nearby views
* - zoom quantized to integer * share a cache entry; all other params are sorted for order-independence.
* - filters, configurable filters, and POI categories sorted alphabetically
*/ */
buildKey(params: URLSearchParams): string { buildKey(params: URLSearchParams): string {
const normalized: Record<string, string> = {}; const normalized: Record<string, string> = {};
// Quantize lat/lon/zoom
const lat = params.get('lat'); const lat = params.get('lat');
const lon = params.get('lon'); const lon = params.get('lon');
const zoom = params.get('zoom'); const zoom = params.get('zoom');
@ -34,44 +32,10 @@ export class ScreenshotCache {
normalized.zoom = Math.round(parseFloat(zoom)).toString(); normalized.zoom = Math.round(parseFloat(zoom)).toString();
} }
// Sort filters const quantized = new Set(['lat', 'lon', 'zoom']);
const filters = params.getAll('filter').sort(); const keys = [...new Set(params.keys())].filter((k) => !quantized.has(k)).sort();
if (filters.length > 0) { for (const key of keys) {
normalized.filters = filters.join(','); normalized[key] = params.getAll(key).sort().join(',');
}
const schools = params.getAll('school').sort();
if (schools.length > 0) {
normalized.school = schools.join(',');
}
const crimes = params.getAll('crime').sort();
if (crimes.length > 0) {
normalized.crime = crimes.join(',');
}
// Sort POI categories
const pois = params.getAll('poi').sort();
if (pois.length > 0) {
normalized.poi = pois.join(',');
}
// Sort travel time entries
const tt = params.getAll('tt').sort();
if (tt.length > 0) {
normalized.tt = tt.join(',');
}
if (params.get('tab')) {
normalized.tab = params.get('tab')!;
}
if (params.get('og')) {
normalized.og = params.get('og')!;
}
if (params.get('path')) {
normalized.path = params.get('path')!;
} }
const input = JSON.stringify(normalized); const input = JSON.stringify(normalized);

View file

@ -1,28 +1,37 @@
import express, { type Request, type Response } from 'express'; import express, { type Request, type Response } from "express";
import { ScreenshotCache } from './cache.js'; import { ScreenshotCache } from "./cache.js";
import { takeScreenshot, checkWebGL, closeBrowser, initialize } from './screenshot.js'; import {
import { buildScreenshotRequest, ValidationError } from './validation.js'; takeScreenshot,
checkWebGL,
closeBrowser,
initialize,
} from "./screenshot.js";
import { buildScreenshotRequest, ValidationError } from "./validation.js";
const PORT = parseInt(process.env.PORT || '8002', 10); const PORT = parseRequiredPositiveIntEnv("PORT");
const APP_URL = process.env.APP_URL; const APP_URL = process.env.APP_URL;
const CACHE_DIR = process.env.CACHE_DIR; const CACHE_DIR = process.env.CACHE_DIR;
const SCREENSHOT_CONCURRENCY = parsePositiveIntEnv('SCREENSHOT_CONCURRENCY', 3); const SCREENSHOT_CONCURRENCY = parseRequiredPositiveIntEnv(
const RATE_LIMIT_WINDOW_MS = parsePositiveIntEnv('SCREENSHOT_RATE_WINDOW_MS', 60_000); "SCREENSHOT_CONCURRENCY",
const RATE_LIMIT_MAX = parsePositiveIntEnv('SCREENSHOT_RATE_LIMIT', 30); );
const RATE_LIMIT_WINDOW_MS = parseRequiredPositiveIntEnv(
"SCREENSHOT_RATE_WINDOW_MS",
);
const RATE_LIMIT_MAX = parseRequiredPositiveIntEnv("SCREENSHOT_RATE_LIMIT");
if (!APP_URL) { if (!APP_URL) {
console.error('Error: APP_URL environment variable is required'); console.error("Error: APP_URL environment variable is required");
process.exit(1); process.exit(1);
} }
if (!CACHE_DIR) { if (!CACHE_DIR) {
console.error('Error: CACHE_DIR environment variable is required'); console.error("Error: CACHE_DIR environment variable is required");
process.exit(1); process.exit(1);
} }
const cache = new ScreenshotCache(CACHE_DIR); const cache = new ScreenshotCache(CACHE_DIR);
const app = express(); const app = express();
app.set('trust proxy', true); app.set("trust proxy", true);
let activeScreenshots = 0; let activeScreenshots = 0;
let lastRateLimitPrune = 0; let lastRateLimitPrune = 0;
@ -34,9 +43,16 @@ type PendingScreenshotSlot = {
}; };
const screenshotSlotQueue: PendingScreenshotSlot[] = []; const screenshotSlotQueue: PendingScreenshotSlot[] = [];
function parsePositiveIntEnv(name: string, fallback: number): number { function parseRequiredPositiveIntEnv(name: string): number {
const value = Number.parseInt(process.env[name] || '', 10); const raw = process.env[name];
return Number.isFinite(value) && value > 0 ? value : fallback; if (!raw) {
throw new Error(`${name} environment variable is required`);
}
const value = Number.parseInt(raw, 10);
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`${name} must be a positive integer`);
}
return value;
} }
function grantScreenshotSlot(): ReleaseScreenshotSlot { function grantScreenshotSlot(): ReleaseScreenshotSlot {
@ -51,7 +67,10 @@ function grantScreenshotSlot(): ReleaseScreenshotSlot {
} }
function drainScreenshotSlotQueue(): void { function drainScreenshotSlotQueue(): void {
while (activeScreenshots < SCREENSHOT_CONCURRENCY && screenshotSlotQueue.length > 0) { while (
activeScreenshots < SCREENSHOT_CONCURRENCY &&
screenshotSlotQueue.length > 0
) {
const pending = screenshotSlotQueue.shift(); const pending = screenshotSlotQueue.shift();
if (!pending) return; if (!pending) return;
pending.cleanup(); pending.cleanup();
@ -59,7 +78,9 @@ function drainScreenshotSlotQueue(): void {
} }
} }
function acquireScreenshotSlot(res: Response): Promise<ReleaseScreenshotSlot | null> { function acquireScreenshotSlot(
res: Response,
): Promise<ReleaseScreenshotSlot | null> {
if (activeScreenshots < SCREENSHOT_CONCURRENCY) { if (activeScreenshots < SCREENSHOT_CONCURRENCY) {
return Promise.resolve(grantScreenshotSlot()); return Promise.resolve(grantScreenshotSlot());
} }
@ -76,17 +97,23 @@ function acquireScreenshotSlot(res: Response): Promise<ReleaseScreenshotSlot | n
pending = { pending = {
resolve, resolve,
cleanup: () => res.off('close', onClose), cleanup: () => res.off("close", onClose),
}; };
res.on('close', onClose); res.on("close", onClose);
screenshotSlotQueue.push(pending); screenshotSlotQueue.push(pending);
console.log(`Queued screenshot request; queue length: ${screenshotSlotQueue.length}`); console.log(
`Queued screenshot request; queue length: ${screenshotSlotQueue.length}`,
);
}); });
} }
function rateLimitKey(req: Request): string { function rateLimitKey(req: Request): string {
const forwardedFor = req.get('x-forwarded-for')?.split(',')[0]?.trim(); const forwardedFor = req.get("x-forwarded-for")?.split(",")[0]?.trim();
return forwardedFor || req.ip || req.socket.remoteAddress || 'unknown'; const key = forwardedFor || req.ip || req.socket.remoteAddress;
if (!key) {
throw new Error("Unable to determine request IP for rate limiting");
}
return key;
} }
function allowScreenshotRequest(req: Request): boolean { function allowScreenshotRequest(req: Request): boolean {
@ -113,11 +140,11 @@ function allowScreenshotRequest(req: Request): boolean {
return true; return true;
} }
app.get('/health', (_req, res) => { app.get("/health", (_req, res) => {
res.status(200).send('ok'); res.status(200).send("ok");
}); });
app.get('/debug', async (_req, res) => { app.get("/debug", async (_req, res) => {
try { try {
const info = await checkWebGL(); const info = await checkWebGL();
res.json(info); res.json(info);
@ -126,32 +153,34 @@ app.get('/debug', async (_req, res) => {
} }
}); });
app.get('/screenshot', async (req, res) => { app.get("/screenshot", async (req, res) => {
let releaseSlot: (() => void) | null = null; let releaseSlot: (() => void) | null = null;
try { try {
const { pagePath, qs } = buildScreenshotRequest(req.query as Record<string, unknown>); const { pagePath, qs } = buildScreenshotRequest(
if (pagePath !== '/') qs.set('path', pagePath); req.query as Record<string, unknown>,
);
if (pagePath !== "/") qs.set("path", pagePath);
// Include auth status in cache key so authenticated screenshots // Include auth status in cache key so authenticated screenshots
// (with hexagons outside free zone) are cached separately // (with hexagons outside free zone) are cached separately
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (authHeader) qs.set('_auth', '1'); if (authHeader) qs.set("_auth", "1");
const cacheKey = cache.buildKey(qs); const cacheKey = cache.buildKey(qs);
qs.delete('_auth'); qs.delete("_auth");
qs.delete('path'); qs.delete("path");
// Check cache first // Check cache first
const cached = cache.get(cacheKey); const cached = cache.get(cacheKey);
if (cached) { if (cached) {
res.setHeader('Content-Type', 'image/jpeg'); res.setHeader("Content-Type", "image/jpeg");
res.setHeader('Cache-Control', 'public, max-age=86400'); res.setHeader("Cache-Control", "public, max-age=86400");
res.setHeader('X-Cache', 'HIT'); res.setHeader("X-Cache", "HIT");
cached.pipe(res); cached.pipe(res);
return; return;
} }
if (!allowScreenshotRequest(req)) { if (!allowScreenshotRequest(req)) {
res.status(429).json({ error: 'Screenshot rate limit exceeded' }); res.status(429).json({ error: "Screenshot rate limit exceeded" });
return; return;
} }
@ -161,26 +190,28 @@ app.get('/screenshot', async (req, res) => {
} }
// Build the URL for the frontend in screenshot mode // Build the URL for the frontend in screenshot mode
qs.set('screenshot', '1'); qs.set("screenshot", "1");
const url = `${APP_URL}${pagePath}?${qs}`; const url = `${APP_URL}${pagePath}?${qs}`;
console.log(`Taking screenshot: ${url}${authHeader ? ' (authenticated)' : ''}`); console.log(
`Taking screenshot: ${url}${authHeader ? " (authenticated)" : ""}`,
);
const jpeg = await takeScreenshot(url, authHeader); const jpeg = await takeScreenshot(url, authHeader);
// Cache it // Cache it
cache.set(cacheKey, jpeg); cache.set(cacheKey, jpeg);
res.setHeader('Content-Type', 'image/jpeg'); res.setHeader("Content-Type", "image/jpeg");
res.setHeader('Cache-Control', 'public, max-age=86400'); res.setHeader("Cache-Control", "public, max-age=86400");
res.setHeader('X-Cache', 'MISS'); res.setHeader("X-Cache", "MISS");
res.send(jpeg); res.send(jpeg);
} catch (err) { } catch (err) {
if (err instanceof ValidationError) { if (err instanceof ValidationError) {
res.status(err.status).json({ error: err.message }); res.status(err.status).json({ error: err.message });
return; return;
} }
console.error('Screenshot failed:', err); console.error("Screenshot failed:", err);
res.status(500).json({ error: 'Screenshot failed' }); res.status(500).json({ error: "Screenshot failed" });
} finally { } finally {
releaseSlot?.(); releaseSlot?.();
} }
@ -191,18 +222,20 @@ const server = app.listen(PORT, () => {
console.log(` APP_URL: ${APP_URL}`); console.log(` APP_URL: ${APP_URL}`);
console.log(` CACHE_DIR: ${CACHE_DIR}`); console.log(` CACHE_DIR: ${CACHE_DIR}`);
console.log(` SCREENSHOT_CONCURRENCY: ${SCREENSHOT_CONCURRENCY}`); console.log(` SCREENSHOT_CONCURRENCY: ${SCREENSHOT_CONCURRENCY}`);
console.log(` SCREENSHOT_RATE_LIMIT: ${RATE_LIMIT_MAX}/${RATE_LIMIT_WINDOW_MS}ms`); console.log(
` SCREENSHOT_RATE_LIMIT: ${RATE_LIMIT_MAX}/${RATE_LIMIT_WINDOW_MS}ms`,
);
// Pre-warm browser and populate network cache in background. // Pre-warm browser and populate network cache in background.
// The health endpoint is available immediately; screenshot requests // The health endpoint is available immediately; screenshot requests
// during warm-up will still work (just slower on the first call). // during warm-up will still work (just slower on the first call).
initialize(APP_URL).catch((err) => { initialize(APP_URL).catch((err) => {
console.error('Initialization failed:', err); console.error("Initialization failed:", err);
}); });
}); });
// Graceful shutdown // Graceful shutdown
for (const signal of ['SIGTERM', 'SIGINT']) { for (const signal of ["SIGTERM", "SIGINT"]) {
process.on(signal, async () => { process.on(signal, async () => {
console.log(`Received ${signal}, shutting down...`); console.log(`Received ${signal}, shutting down...`);
server.close(); server.close();

View file

@ -14,6 +14,7 @@ test('buildScreenshotRequest accepts supported screenshot parameters', () => {
filter: ['Last known price:100000:500000', 'Total floor area (sqm):50:150'], filter: ['Last known price:100000:500000', 'Total floor area (sqm):50:150'],
school: 'primary:good:2:1:10', school: 'primary:good:2:1:10',
crime: ['Burglary (avg/yr):0:5', 'Vehicle crime (avg/yr):0:10'], crime: ['Burglary (avg/yr):0:5', 'Vehicle crime (avg/yr):0:10'],
ethnicity: ['% White:10:80', '% South Asian:5:35'],
poi: 'supermarket', poi: 'supermarket',
tt: 'transit:kings-cross:Kings Cross:b:0:30', tt: 'transit:kings-cross:Kings Cross:b:0:30',
}); });
@ -32,6 +33,7 @@ test('buildScreenshotRequest accepts supported screenshot parameters', () => {
'Burglary (avg/yr):0:5', 'Burglary (avg/yr):0:5',
'Vehicle crime (avg/yr):0:10', 'Vehicle crime (avg/yr):0:10',
]); ]);
assert.deepEqual(result.qs.getAll('ethnicity'), ['% White:10:80', '% South Asian:5:35']);
}); });
test('buildScreenshotRequest rejects invalid numeric values', () => { test('buildScreenshotRequest rejects invalid numeric values', () => {

View file

@ -12,7 +12,7 @@ const MAX_VALUE_LENGTH = 500;
const NUMERIC_RE = /^-?(?:\d+|\d*\.\d+)$/; const NUMERIC_RE = /^-?(?:\d+|\d*\.\d+)$/;
const PATH_RE = /^\/(?:invite\/[A-Za-z0-9]{1,20})?$/; const PATH_RE = /^\/(?:invite\/[A-Za-z0-9]{1,20})?$/;
const SAFE_VALUE_RE = /^[^\u0000-\u001f\u007f]+$/; const SAFE_VALUE_RE = /^[^\u0000-\u001f\u007f]+$/;
const REPEATED_KEYS = ['filter', 'school', 'crime', 'poi', 'tt'] as const; const REPEATED_KEYS = ['filter', 'school', 'crime', 'ethnicity', 'poi', 'tt'] as const;
type Query = Record<string, unknown>; type Query = Record<string, unknown>;

View file

@ -4,10 +4,40 @@ mod postcodes;
mod property; mod property;
pub mod travel_time; pub mod travel_time;
fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String {
if let Some(message) = payload.downcast_ref::<&'static str>() {
(*message).to_string()
} else if let Some(message) = payload.downcast_ref::<String>() {
message.clone()
} else {
"unknown panic payload".to_string()
}
}
pub(super) fn run_polars_io<T, F>(work: F) -> anyhow::Result<T>
where
T: Send,
F: FnOnce() -> anyhow::Result<T> + Send,
{
if tokio::runtime::Handle::try_current().is_err() {
return work();
}
std::thread::scope(|scope| {
scope.spawn(work).join().map_err(|payload| {
anyhow::anyhow!(
"Polars worker thread panicked: {}",
panic_payload_message(payload.as_ref())
)
})?
})
}
pub use places::{normalize_search_text, PlaceData}; pub use places::{normalize_search_text, PlaceData};
pub use poi::{POICategoryGroup, POIData}; pub use poi::{resolve_poi_category_filter, POICategoryGroup, POIData};
pub use postcodes::{OutcodeData, PostcodeData}; pub use postcodes::{OutcodeData, PostcodeData};
pub use property::{ pub use property::{
precompute_h3, FeatureStats, Histogram, PropertyData, QuantRef, RenovationEvent, precompute_h3, FeatureStats, Histogram, PostcodePoiMetrics, PropertyData, QuantRef,
RenovationEvent,
}; };
pub use travel_time::{slugify, TravelTimeStore}; pub use travel_time::{slugify, TravelTimeStore};

View file

@ -13,7 +13,7 @@ use crate::auth::OptionalUser;
use crate::consts::{AI_FILTERS_MAX_TOKENS, AI_FILTERS_TEMPERATURE, AI_FILTERS_WEEKLY_TOKEN_LIMIT}; use crate::consts::{AI_FILTERS_MAX_TOKENS, AI_FILTERS_TEMPERATURE, AI_FILTERS_WEEKLY_TOKEN_LIMIT};
use crate::data::slugify; use crate::data::slugify;
use crate::data::travel_time::TravelData; use crate::data::travel_time::TravelData;
use crate::parsing::{parse_filters, row_passes_filters}; use crate::parsing::{parse_filters_with_poi, row_passes_filters, row_passes_poi_filters};
use crate::pocketbase::{get_superuser_token, log_ai_query}; use crate::pocketbase::{get_superuser_token, log_ai_query};
use crate::routes::{FeatureInfo, FeaturesResponse}; use crate::routes::{FeatureInfo, FeaturesResponse};
use crate::state::{AppState, SharedState}; use crate::state::{AppState, SharedState};
@ -655,14 +655,17 @@ fn count_matching_rows(
let filter_str = filters_to_filter_string(filters); let filter_str = filters_to_filter_string(filters);
let quant = state.data.quant_ref(); let quant = state.data.quant_ref();
let (parsed_filters, parsed_enum_filters) = if filter_str.is_empty() { let poi_quant = state.data.poi_metrics.quant_ref();
(Vec::new(), Vec::new()) let (parsed_filters, parsed_enum_filters, parsed_poi_filters) = if filter_str.is_empty() {
(Vec::new(), Vec::new(), Vec::new())
} else { } else {
match parse_filters( match parse_filters_with_poi(
Some(&filter_str), Some(&filter_str),
&state.feature_name_to_index, &state.feature_name_to_index,
&state.data.enum_values, &state.data.enum_values,
&quant, &quant,
&state.data.poi_metrics.name_to_index,
&poi_quant,
) { ) {
Ok(f) => f, Ok(f) => f,
Err(err) => { Err(err) => {
@ -686,6 +689,7 @@ fn count_matching_rows(
let num_features = state.data.num_features; let num_features = state.data.num_features;
let num_rows = state.data.lat.len(); let num_rows = state.data.lat.len();
let (pc_interner, pc_keys) = state.data.postcode_parts(); let (pc_interner, pc_keys) = state.data.postcode_parts();
let has_poi_filters = !parsed_poi_filters.is_empty();
let mut count = 0usize; let mut count = 0usize;
for (row, pc_key) in pc_keys.iter().enumerate().take(num_rows) { for (row, pc_key) in pc_keys.iter().enumerate().take(num_rows) {
@ -698,6 +702,11 @@ fn count_matching_rows(
) { ) {
continue; continue;
} }
if has_poi_filters
&& !row_passes_poi_filters(row, &parsed_poi_filters, &state.data.poi_metrics)
{
continue;
}
if has_travel { if has_travel {
let postcode = pc_interner.resolve(pc_key); let postcode = pc_interner.resolve(pc_key);

View file

@ -4,7 +4,7 @@ use axum::extract::{Path, State};
use axum::http::{header, StatusCode}; use axum::http::{header, StatusCode};
use axum::response::{Html, IntoResponse, Response}; use axum::response::{Html, IntoResponse, Response};
use axum::Json; use axum::Json;
use rand::Rng; use rand::RngExt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::warn; use tracing::warn;

View file

@ -3,8 +3,7 @@ use std::sync::Arc;
use axum::extract::{Path, Query, State}; use axum::extract::{Path, Query, State};
use axum::http::{header, StatusCode}; use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use pmtiles::async_reader::AsyncPmTilesReader; use pmtiles::{AsyncPmTilesReader, MmapBackend, TileCoord};
use pmtiles::MmapBackend;
use serde::Deserialize; use serde::Deserialize;
use tracing::warn; use tracing::warn;
@ -14,7 +13,15 @@ pub async fn get_tile(
State(reader): State<Arc<TileReader>>, State(reader): State<Arc<TileReader>>,
Path((zoom, col, row)): Path<(u8, u32, u32)>, Path((zoom, col, row)): Path<(u8, u32, u32)>,
) -> Response { ) -> Response {
match reader.get_tile(zoom, col as u64, row as u64).await { let tile_coord = match TileCoord::new(zoom, col, row) {
Ok(tile_coord) => tile_coord,
Err(err) => {
warn!(zoom, col, row, error = %err, "Invalid tile coordinate");
return StatusCode::BAD_REQUEST.into_response();
}
};
match reader.get_tile(tile_coord).await {
Ok(Some(tile_bytes)) => ( Ok(Some(tile_bytes)) => (
StatusCode::OK, StatusCode::OK,
[ [

View file

@ -10,15 +10,17 @@
# ./render.sh # full pipeline (uses cached auth.json if fresh) # ./render.sh # full pipeline (uses cached auth.json if fresh)
# ./render.sh --fresh-auth # force re-auth even if auth.json exists # ./render.sh --fresh-auth # force re-auth even if auth.json exists
# ./render.sh --no-encode # stop at WebM, skip MP4 encode # ./render.sh --no-encode # stop at WebM, skip MP4 encode
# ./render.sh --no-audio # skip Qwen3-TTS narration; publish silent MP4
# FORCE_AUTH=1 ./render.sh # same as --fresh-auth # FORCE_AUTH=1 ./render.sh # same as --fresh-auth
# APP_URL=http://localhost:3001 ./render.sh # override frontend URL # APP_URL=http://localhost:3001 ./render.sh # override frontend URL
# TTS_SPEAKER=aiden ./render.sh # override CustomVoice speaker
set -euo pipefail set -euo pipefail
# -- config (override via env) ------------------------------------------------- # -- config (override via env) -------------------------------------------------
APP_URL="${APP_URL:-http://host.docker.internal:3001}" export APP_URL="${APP_URL:-http://host.docker.internal:3001}"
PB_URL="${PB_URL:-http://host.docker.internal:8090}" export PB_URL="${PB_URL:-http://host.docker.internal:8090}"
API_URL="${API_URL:-http://host.docker.internal:8001}" export API_URL="${API_URL:-http://host.docker.internal:8001}"
PB_ADMIN_EMAIL="${PB_ADMIN_EMAIL:-admin@propertymap.local}" PB_ADMIN_EMAIL="${PB_ADMIN_EMAIL:-admin@propertymap.local}"
PB_ADMIN_PASSWORD="${PB_ADMIN_PASSWORD:-propertymap-dev-2024}" PB_ADMIN_PASSWORD="${PB_ADMIN_PASSWORD:-propertymap-dev-2024}"
PB_EMAIL="${PB_EMAIL:-demo-video@local.test}" PB_EMAIL="${PB_EMAIL:-demo-video@local.test}"
@ -34,14 +36,28 @@ PUBLISH_DIR="${PUBLISH_DIR:-../frontend/public/video}"
# caption visible. # caption visible.
POSTER_TIME_S="${POSTER_TIME_S:-16}" POSTER_TIME_S="${POSTER_TIME_S:-16}"
# Recorder/encoder knobs read by src/config.ts. config.ts treats these as
# required, so they live here (the only entry point) rather than as defaults
# scattered across TS modules. Override per-run via env.
export ASPECT="${ASPECT:-16x9}"
export CAPTURE_SCALE="${CAPTURE_SCALE:-1}"
export WEBM_BITRATE="${WEBM_BITRATE:-$(awk -v s="$CAPTURE_SCALE" 'BEGIN{print (s+0>1)?"18M":"8M"}')}"
export PROMPT_TEXT="${PROMPT_TEXT:-Flats or terraces <£450k, 35 min to Manchester, low crime}"
export AI_ZOOM_SCALE="${AI_ZOOM_SCALE:-2.4}"
export MAX_DURATION_S="${MAX_DURATION_S:-45}"
export MIN_DURATION_S="${MIN_DURATION_S:-10}"
export OUTPUT_FPS="${OUTPUT_FPS:-50}"
FRESH_AUTH="${FORCE_AUTH:-0}" FRESH_AUTH="${FORCE_AUTH:-0}"
DO_ENCODE=1 DO_ENCODE=1
DO_AUDIO=1
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
--fresh-auth) FRESH_AUTH=1 ;; --fresh-auth) FRESH_AUTH=1 ;;
--no-encode) DO_ENCODE=0 ;; --no-encode) DO_ENCODE=0 ;;
--no-audio) DO_AUDIO=0 ;;
-h|--help) -h|--help)
sed -n '3,18p' "$0" sed -n '3,20p' "$0"
exit 0 ;; exit 0 ;;
*) echo "Unknown arg: $arg" >&2; exit 2 ;; *) echo "Unknown arg: $arg" >&2; exit 2 ;;
esac esac
@ -124,12 +140,36 @@ else
say "Reusing existing auth.json" say "Reusing existing auth.json"
fi fi
# -- record ------------------------------------------------------------------- # -- preflight + synth (Qwen3-TTS) -------------------------------------------
say "Recording" # Synth runs BEFORE recording: one batched generate_custom_voice call across
# all cues so the voice stays consistent. The recorder reads
# output/audio/index.json for measured per-cue durations and sizes each
# cue's wall-clock to fit; --no-audio skips synth and the recorder falls
# back to a worst-case estimate.
mkdir -p output mkdir -p output
# Wipe last run's leaking artifacts so the rename step picks up *this* run. # Wipe last run's leaking artifacts so the rename step picks up *this* run.
rm -f output/recording.webm output/recording.mp4 output/page@*.webm output/page@*.webm.untrimmed rm -f output/recording.webm output/recording.mp4 output/page@*.webm output/page@*.webm.untrimmed
rm -f output/narration-script.json output/narration.json
# output/audio/ is preserved; tts/synth.py decides whether the cached WAVs
# still match the script and skips generation when they do.
say "Preflight: emitting narration script"
node dist/preflight.js
if [ "$DO_AUDIO" = "1" ]; then
if ! command -v uv >/dev/null 2>&1; then
fail "uv not on PATH (required for Qwen3-TTS synth). Install uv or rerun with --no-audio."
fi
say "Synthesising narration with Qwen3-TTS (speaker=${TTS_SPEAKER:-ryan}) — one batched call"
uv sync --project tts || fail "uv sync failed in video/tts"
uv run --project tts python tts/synth.py || fail "tts/synth.py failed"
if [ ! -s output/audio/index.json ]; then
fail "synth did not produce output/audio/index.json"
fi
fi
# -- record -------------------------------------------------------------------
say "Recording"
APP_URL="$APP_URL" node dist/record.js APP_URL="$APP_URL" node dist/record.js
if [ ! -s output/recording.webm ]; then if [ ! -s output/recording.webm ]; then
@ -163,6 +203,20 @@ if [ "$DO_ENCODE" = "1" ]; then
node dist/verify.js output/recording.mp4 output/poster.jpg node dist/verify.js output/recording.mp4 output/poster.jpg
fi fi
# -- mux narration ------------------------------------------------------------
# Synth already produced per-cue WAVs (in output/audio/); the recorder logged
# each cue's videoTime against the trimmed timeline. Drop the WAVs onto the
# mp4 with one ffmpeg adelay+amix and replace the silent recording in place.
if [ "$DO_ENCODE" = "1" ] && [ "$DO_AUDIO" = "1" ]; then
if [ ! -s output/narration.json ]; then
fail "narration.json missing — recorder did not log cues"
fi
say "Muxing narration into output/recording.mp4"
uv run --project tts python tts/mux.py --replace \
|| fail "tts/mux.py failed"
node dist/verify.js output/recording.mp4
fi
# -- publish to homepage ------------------------------------------------------ # -- publish to homepage ------------------------------------------------------
# Only publish when we did the encode (otherwise we'd be copying a stale # Only publish when we did the encode (otherwise we'd be copying a stale
# mp4 next to a fresh webm). --no-encode skips this whole block. # mp4 next to a fresh webm). --no-encode skips this whole block.

View file

@ -1,5 +1,16 @@
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'; import {
import { AUTH_STATE_PATH, CAPTURE_SCALE, OUTPUT_DIR, VIDEO_SIZE, VIEWPORT } from './config.js'; chromium,
type Browser,
type BrowserContext,
type Page,
} from "playwright";
import {
AUTH_STATE_PATH,
CAPTURE_SCALE,
OUTPUT_DIR,
VIDEO_SIZE,
VIEWPORT,
} from "./config.js";
export interface RecordingBrowser { export interface RecordingBrowser {
browser: Browser; browser: Browser;
@ -10,22 +21,22 @@ export async function launchRecordingBrowser(): Promise<RecordingBrowser> {
const browser = await chromium.launch({ const browser = await chromium.launch({
headless: true, headless: true,
args: [ args: [
'--disable-blink-features=AutomationControlled', "--disable-blink-features=AutomationControlled",
'--enable-gpu', "--enable-gpu",
'--use-gl=angle', "--use-gl=angle",
'--use-angle=gl-egl', "--use-angle=gl-egl",
'--ignore-gpu-blocklist', "--ignore-gpu-blocklist",
'--enable-webgl', "--enable-webgl",
'--enable-webgl2', "--enable-webgl2",
'--enable-gpu-rasterization', "--enable-gpu-rasterization",
'--enable-zero-copy', "--enable-zero-copy",
'--disable-software-rasterizer', "--disable-software-rasterizer",
'--disable-frame-rate-limit', "--disable-frame-rate-limit",
'--disable-gpu-vsync', "--disable-gpu-vsync",
'--disable-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling', "--disable-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling",
'--disable-renderer-backgrounding', "--disable-renderer-backgrounding",
'--disable-background-timer-throttling', "--disable-background-timer-throttling",
'--disable-backgrounding-occluded-windows', "--disable-backgrounding-occluded-windows",
], ],
}); });
@ -41,27 +52,34 @@ export async function launchRecordingBrowser(): Promise<RecordingBrowser> {
export async function assertHardwareWebGL(page: Page): Promise<void> { export async function assertHardwareWebGL(page: Page): Promise<void> {
const info = await page.evaluate(() => { const info = await page.evaluate(() => {
const canvas = document.createElement('canvas'); const canvas = document.createElement("canvas");
const gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl'); const gl = canvas.getContext("webgl2");
if (!gl) return { webgl: false, vendor: '', renderer: '' }; if (!gl) return { webgl: false, vendor: "", renderer: "" };
const ext = gl.getExtension('WEBGL_debug_renderer_info'); const ext = gl.getExtension("WEBGL_debug_renderer_info");
const vendor = String( const vendor = String(
ext ? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR) ext
? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL)
: gl.getParameter(gl.VENDOR),
); );
const renderer = String( const renderer = String(
ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER) ext
? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)
: gl.getParameter(gl.RENDERER),
); );
return { webgl: true, vendor, renderer }; return { webgl: true, vendor, renderer };
}); });
console.log(`[gpu] WebGL renderer: ${info.webgl ? `${info.vendor} / ${info.renderer}` : 'none'}`); console.log(
`[gpu] WebGL renderer: ${info.webgl ? `${info.vendor} / ${info.renderer}` : "none"}`,
);
if ( if (
process.env.ALLOW_SOFTWARE_GL !== '1' && process.env.ALLOW_SOFTWARE_GL !== "1" &&
(!info.webgl || /SwiftShader|llvmpipe|software/i.test(`${info.vendor} ${info.renderer}`)) (!info.webgl ||
/SwiftShader|llvmpipe|software/i.test(`${info.vendor} ${info.renderer}`))
) { ) {
throw new Error( throw new Error(
'Recording browser did not get hardware WebGL. Set ALLOW_SOFTWARE_GL=1 to bypass this guard.' "Recording browser did not get hardware WebGL. Set ALLOW_SOFTWARE_GL=1 to bypass this guard.",
); );
} }
} }
@ -71,41 +89,45 @@ async function suppressDevServerNoise(context: BrowserContext) {
const RealWS = window.WebSocket; const RealWS = window.WebSocket;
window.WebSocket = new Proxy(RealWS, { window.WebSocket = new Proxy(RealWS, {
construct(target, args) { construct(target, args) {
const url = String(args[0] ?? ''); const url = String(args[0] ?? "");
const proto = (args[1] as string | string[] | undefined) ?? ''; const proto = (args[1] as string | string[] | undefined) ?? "";
const protoStr = Array.isArray(proto) ? proto.join(',') : proto; const protoStr = Array.isArray(proto) ? proto.join(",") : proto;
if ( if (
protoStr.includes('vite-hmr') || protoStr.includes("vite-hmr") ||
protoStr.includes('webpack') || protoStr.includes("webpack") ||
url.includes('/ws') || url.includes("/ws") ||
url.includes('sockjs-node') url.includes("sockjs-node")
) { ) {
const fake = new EventTarget() as WebSocket; const fake = new EventTarget() as WebSocket;
Object.defineProperties(fake, { Object.defineProperties(fake, {
readyState: { value: RealWS.CLOSED }, readyState: { value: RealWS.CLOSED },
url: { value: url }, url: { value: url },
protocol: { value: '' }, protocol: { value: "" },
extensions: { value: '' }, extensions: { value: "" },
bufferedAmount: { value: 0 }, bufferedAmount: { value: 0 },
binaryType: { value: 'blob', writable: true }, binaryType: { value: "blob", writable: true },
}); });
fake.send = () => {}; fake.send = () => {};
fake.close = () => fake.dispatchEvent(new Event('close')); fake.close = () => fake.dispatchEvent(new Event("close"));
queueMicrotask(() => fake.dispatchEvent(new Event('close'))); queueMicrotask(() => fake.dispatchEvent(new Event("close")));
return fake; return fake;
} }
return Reflect.construct(target, args); return Reflect.construct(target, args);
}, },
}); });
Object.defineProperty(window.location, 'reload', { Object.defineProperty(window.location, "reload", {
value: () => {}, value: () => {},
configurable: true, configurable: true,
}); });
window.addEventListener('error', (e) => e.stopImmediatePropagation(), true); window.addEventListener("error", (e) => e.stopImmediatePropagation(), true);
window.addEventListener('unhandledrejection', (e) => e.stopImmediatePropagation(), true); window.addEventListener(
"unhandledrejection",
(e) => e.stopImmediatePropagation(),
true,
);
const styleEl = document.createElement('style'); const styleEl = document.createElement("style");
styleEl.textContent = ` styleEl.textContent = `
vite-error-overlay, vite-error-overlay,
wds-overlay, wds-overlay,
@ -126,12 +148,12 @@ async function suppressDevServerNoise(context: BrowserContext) {
const killOverlay = (node: Element) => { const killOverlay = (node: Element) => {
const tag = node.tagName?.toLowerCase(); const tag = node.tagName?.toLowerCase();
const id = (node as HTMLElement).id?.toLowerCase() ?? ''; const id = (node as HTMLElement).id?.toLowerCase() ?? "";
if ( if (
tag === 'vite-error-overlay' || tag === "vite-error-overlay" ||
tag === 'wds-overlay' || tag === "wds-overlay" ||
id.includes('webpack-dev-server-client') || id.includes("webpack-dev-server-client") ||
id.includes('webpack-error') id.includes("webpack-error")
) { ) {
(node as HTMLElement).remove(); (node as HTMLElement).remove();
} }
@ -143,10 +165,11 @@ async function suppressDevServerNoise(context: BrowserContext) {
}); });
} }
}); });
if (document.body) obs.observe(document.body, { childList: true, subtree: true }); if (document.body)
obs.observe(document.body, { childList: true, subtree: true });
else { else {
document.addEventListener('DOMContentLoaded', () => document.addEventListener("DOMContentLoaded", () =>
obs.observe(document.body, { childList: true, subtree: true }) obs.observe(document.body, { childList: true, subtree: true }),
); );
} }
}); });

View file

@ -1,46 +1,66 @@
export const APP_URL = process.env.APP_URL ?? 'http://host.docker.internal:3001'; function requiredEnv(name: string): string {
export const DASHBOARD_PATH = '/dashboard'; const value = process.env[name];
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}
export const AUTH_STATE_PATH = 'auth.json'; function requiredNumberEnv(name: string): number {
export const OUTPUT_DIR = 'output'; const value = Number(requiredEnv(name));
if (!Number.isFinite(value)) {
throw new Error(`${name} must be a finite number`);
}
return value;
}
const aspect = process.env.ASPECT ?? '16x9'; export const APP_URL = requiredEnv("APP_URL");
export const DASHBOARD_PATH = "/dashboard";
export const AUTH_STATE_PATH = "auth.json";
export const OUTPUT_DIR = "output";
const aspect = requiredEnv("ASPECT");
if (aspect !== "16x9" && aspect !== "9x16") {
throw new Error("ASPECT must be '16x9' or '9x16'");
}
export const VIEWPORT = export const VIEWPORT =
aspect === '9x16' ? { width: 1080, height: 1920 } : { width: 1920, height: 1080 }; aspect === "9x16"
export const CAPTURE_SCALE = Math.max(1, Number(process.env.CAPTURE_SCALE ?? 1)); ? { width: 1080, height: 1920 }
: { width: 1920, height: 1080 };
export const CAPTURE_SCALE = Math.max(1, requiredNumberEnv("CAPTURE_SCALE"));
export const VIDEO_SIZE = { export const VIDEO_SIZE = {
width: VIEWPORT.width, width: VIEWPORT.width,
height: VIEWPORT.height, height: VIEWPORT.height,
}; };
export const WEBM_BITRATE = process.env.WEBM_BITRATE ?? (CAPTURE_SCALE > 1 ? '18M' : '8M'); export const WEBM_BITRATE = requiredEnv("WEBM_BITRATE");
// Cold-open prompt. Punchy version of the user's intent, short enough to type // Cold-open prompt. Punchy version of the user's intent, short enough to type
// on camera without making the opening scene drag. // on camera without making the opening scene drag.
export const PROMPT_TEXT = export const PROMPT_TEXT = requiredEnv("PROMPT_TEXT");
process.env.PROMPT_TEXT ?? 'Flats or terraces <£450k, 35 min to Manchester, low crime';
// Filters returned by the AI stub. Keys MUST match real feature names from // Filters returned by the AI stub. Keys MUST match real feature names from
// /api/features (verified against the running server's schema). // /api/features (verified against the running server's schema).
export const STUBBED_FILTERS: Record<string, [number, number] | string[]> = { export const STUBBED_FILTERS: Record<string, [number, number] | string[]> = {
'Property type': ['Flats/Maisonettes', 'Terraced'], "Property type": ["Flats/Maisonettes", "Terraced"],
'Estimated current price': [175000, 450000], "Estimated current price": [175000, 450000],
'Serious crime per 1k residents (avg/yr)': [0, 55], "Serious crime per 1k residents (avg/yr)": [0, 55],
'Noise (dB)': [50, 68], "Noise (dB)": [50, 68],
}; };
// Travel-time filters returned by the AI stub. Slug matches the real // Travel-time filters returned by the AI stub. Slug matches the real
// /api/travel-destinations?mode=transit response. // /api/travel-destinations?mode=transit response.
export const STUBBED_TRAVEL_TIME_FILTERS: { export const STUBBED_TRAVEL_TIME_FILTERS: {
mode: 'transit' | 'car' | 'bicycle' | 'walking'; mode: "transit" | "car" | "bicycle" | "walking";
slug: string; slug: string;
label: string; label: string;
min?: number; min?: number;
max?: number; max?: number;
}[] = [ }[] = [
{ {
mode: 'transit', mode: "transit",
slug: 'manchester', slug: "manchester",
label: 'Manchester city centre', label: "Manchester city centre",
max: 35, max: 35,
}, },
]; ];
@ -55,7 +75,7 @@ export const TT_DRAG_TO_MIN = 20;
// Cold-open zoom: how aggressively to magnify the AI box. // Cold-open zoom: how aggressively to magnify the AI box.
// 2.4 fills most of the viewport with the prompt card without blowing up text. // 2.4 fills most of the viewport with the prompt card without blowing up text.
export const AI_ZOOM_SCALE = Number(process.env.AI_ZOOM_SCALE ?? 2.4); export const AI_ZOOM_SCALE = requiredNumberEnv("AI_ZOOM_SCALE");
// Initial map view used while we navigate. The AI scene zooms in on the // Initial map view used while we navigate. The AI scene zooms in on the
// sidebar so this only matters once we zoom out. // sidebar so this only matters once we zoom out.
@ -67,13 +87,18 @@ export const INITIAL_MAP_VIEW = {
// Verification guard only. The renderer does not use this as an editing cap: // Verification guard only. The renderer does not use this as an editing cap:
// if the storyboard needs more than 15 seconds to avoid jumps, keep the frames. // if the storyboard needs more than 15 seconds to avoid jumps, keep the frames.
export const MAX_DURATION_S = Number(process.env.MAX_DURATION_S ?? 45); export const MAX_DURATION_S = requiredNumberEnv("MAX_DURATION_S");
export const MIN_DURATION_S = Number(process.env.MIN_DURATION_S ?? 10); export const MIN_DURATION_S = requiredNumberEnv("MIN_DURATION_S");
// Target fps of the FINAL output. // Target fps of the FINAL output.
export const OUTPUT_FPS = Number(process.env.OUTPUT_FPS ?? 50); export const OUTPUT_FPS = requiredNumberEnv("OUTPUT_FPS");
// Frames of head-room kept in front of sceneStart when trimming. Shared by
// the video trim and the narration manifest so cue offsets line up with the
// trimmed timeline.
export const LEAD_IN_S = 0.12;
// Brand strings for the outro card. // Brand strings for the outro card.
export const BRAND_NAME = 'Perfect Postcode'; export const BRAND_NAME = "Perfect Postcode";
export const BRAND_TAGLINE = 'Find where you actually want to live.'; export const BRAND_TAGLINE = "Find where you actually want to live.";
export const BRAND_URL = 'https://perfect-postcode.co.uk'; export const BRAND_URL = "https://perfect-postcode.co.uk";

View file

@ -20,8 +20,10 @@ export async function installCursor(page: Page): Promise<void> {
pointer-events: none; pointer-events: none;
z-index: 2147483646; z-index: 2147483646;
transform: translate(-2px, -2px); transform: translate(-2px, -2px);
transform-origin: 2px 2px;
transition: transform 60ms linear, scale 120ms ease-out; transition: transform 60ms linear, scale 120ms ease-out;
will-change: transform; will-change: transform, scale;
scale: 1;
} }
#__demo-cursor svg { #__demo-cursor svg {
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.35)); filter: drop-shadow(0 2px 4px rgba(0,0,0,0.35));
@ -225,6 +227,30 @@ export async function showCaption(page: Page, text: string): Promise<void> {
}, text); }, text);
} }
/**
* Animate the visible cursor to a new CSS scale. The injected cursor element
* uses the `scale` shorthand (separate from `transform: translate(...)`),
* which means resizing it doesn't fight the per-frame translate updates from
* mousemove. The transition duration is set inline so each call decides its
* own pace.
*/
export async function setCursorScale(
page: Page,
scale: number,
durationMs: number
): Promise<void> {
await page.evaluate(
({ scale, durationMs }) => {
const cursor = document.getElementById('__demo-cursor');
if (!cursor) return;
cursor.style.transition =
`transform 60ms linear, scale ${Math.max(0, durationMs)}ms cubic-bezier(0.22, 1, 0.36, 1)`;
cursor.style.scale = String(scale);
},
{ scale, durationMs }
);
}
export async function hideCaption(page: Page): Promise<void> { export async function hideCaption(page: Page): Promise<void> {
await page.evaluate(() => { await page.evaluate(() => {
document.getElementById('__demo-caption')?.classList.remove('visible'); document.getElementById('__demo-caption')?.classList.remove('visible');

View file

@ -72,18 +72,31 @@ export async function smoothMove(
/** /**
* "Fake" type: progressively set the textarea value, dispatching * "Fake" type: progressively set the textarea value, dispatching
* React-compatible input events. This stays Node-driven so typing cadence is * React-compatible input events.
* stable even when the map is busy rendering. *
* Cadence is generated as a per-char weight ratio (so spaces and punctuation
* read as natural pauses), then **rescaled** so that the sum of delays equals
* `totalDurationMs` exactly. The runner depends on this: it budgets a
* specific number of ms for the type step, and any divergence would cascade
* into narration drift.
*/ */
export async function fakeType( export async function fakeType(
page: Page, page: Page,
selector: string, selector: string,
text: string, text: string,
delayMs: number totalDurationMs: number
): Promise<void> { ): Promise<void> {
const steps = text.length; const steps = text.length;
if (steps === 0) {
if (totalDurationMs > 0) await sleep(totalDurationMs);
return;
}
const weights = computeTypingWeights(text);
const weightSum = weights.reduce((a, b) => a + b, 0);
const msPerWeight = totalDurationMs / weightSum;
for (let i = 1; i <= steps; i++) { for (let i = 1; i <= steps; i++) {
const end = Math.ceil((text.length * i) / steps);
await page.evaluate( await page.evaluate(
({ selector, value }) => { ({ selector, value }) => {
const ta = document.querySelector(selector) as HTMLTextAreaElement | null; const ta = document.querySelector(selector) as HTMLTextAreaElement | null;
@ -97,28 +110,25 @@ export async function fakeType(
setValue.call(ta, value); setValue.call(ta, value);
ta.dispatchEvent(new Event('input', { bubbles: true })); ta.dispatchEvent(new Event('input', { bubbles: true }));
}, },
{ selector, value: text.slice(0, end) } { selector, value: text.slice(0, i) }
); );
if (delayMs > 0 && i < steps) { if (i < steps) {
await new Promise((resolve) => const ms = Math.max(0, Math.round(weights[i - 1] * msPerWeight));
setTimeout(resolve, humanTypingDelay(text[i - 1], text[i], i, delayMs)) if (ms > 0) await sleep(ms);
);
} }
} }
} }
function humanTypingDelay( function computeTypingWeights(text: string): number[] {
char: string,
nextChar: string | undefined,
index: number,
baseDelayMs: number
): number {
const cadence = [0.82, 1.08, 0.94, 1.22, 0.88, 1.14, 0.98, 1.28]; const cadence = [0.82, 1.08, 0.94, 1.22, 0.88, 1.14, 0.98, 1.28];
let delay = baseDelayMs * cadence[index % cadence.length]; return Array.from(text, (char, index) => {
if (char === ' ') delay += baseDelayMs * 0.9; let weight = cadence[index % cadence.length];
if (/[,.!?;:]/.test(char)) delay += baseDelayMs * 1.8; if (char === ' ') weight += 0.9;
if (nextChar === ' ' && index % 4 === 0) delay += baseDelayMs * 0.55; if (/[,.!?;:]/.test(char)) weight += 1.8;
return Math.round(delay); const next = text[index + 1];
if (next === ' ' && index % 4 === 0) weight += 0.55;
return weight;
});
} }
/** /**

37
video/src/narration.ts Normal file
View file

@ -0,0 +1,37 @@
import { writeFileSync } from 'node:fs';
export interface NarrationCue {
text: string;
videoTimeMs: number;
durationMs: number;
}
/**
* Narration manifest writer.
*
* The runner knows the exact video-time of each narration block from the
* storyboard itself, so cues come in with an explicit `videoTimeMs` instead
* of being stamped against a wall-clock origin. That keeps the manifest in
* lockstep with the trimmed video even if step durations drift slightly.
*/
class NarrationLog {
private cues: NarrationCue[] = [];
reset(): void {
this.cues = [];
}
add(cue: NarrationCue): void {
if (cue.videoTimeMs < 0) return;
this.cues.push(cue);
}
flush(path: string, totalDurationMs: number): NarrationCue[] {
const sorted = [...this.cues].sort((a, b) => a.videoTimeMs - b.videoTimeMs);
const manifest = { totalDurationMs, cues: sorted };
writeFileSync(path, JSON.stringify(manifest, null, 2));
return sorted;
}
}
export const narrationLog = new NarrationLog();

32
video/src/preflight.ts Normal file
View file

@ -0,0 +1,32 @@
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { OUTPUT_DIR } from './config.js';
import { storyboard } from './storyboard.js';
/**
* Emit the narration script for the synth step.
*
* Synth (tts/synth.py) runs BEFORE recording, so it needs the full ordered
* narration list text + per-cue gaps without depending on Playwright,
* the dashboard, or auth. Walk the storyboard cues, write a flat manifest,
* exit.
*
* The cue index in this manifest is the source of truth: the runner later
* matches storyboard cues to measured durations by index.
*/
function main(): void {
if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true });
const items = storyboard.cues.map((cue, cueIndex) => ({
cueIndex,
text: cue.text.trim(),
gapBeforeMs: cue.gapBeforeMs,
}));
const manifest = { items };
const path = join(OUTPUT_DIR, 'narration-script.json');
writeFileSync(path, JSON.stringify(manifest, null, 2));
console.log(`Wrote ${items.length} narration cues to ${path}`);
}
main();

View file

@ -1,8 +1,10 @@
import { existsSync, mkdirSync, statSync } from 'node:fs'; import { existsSync, mkdirSync, statSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { AUTH_STATE_PATH, OUTPUT_DIR } from './config.js'; import { AUTH_STATE_PATH, LEAD_IN_S, OUTPUT_DIR } from './config.js';
import { assertHardwareWebGL, launchRecordingBrowser } from './browser.js'; import { assertHardwareWebGL, launchRecordingBrowser } from './browser.js';
import { narrationLog } from './narration.js';
import { installDemoRoutes } from './routes.js'; import { installDemoRoutes } from './routes.js';
import { storyboard } from './storyboard.js';
import { prepareTimeline, runTimeline } from './timeline.js'; import { prepareTimeline, runTimeline } from './timeline.js';
import { trimRecording } from './video.js'; import { trimRecording } from './video.js';
@ -37,7 +39,7 @@ async function main() {
await installDemoRoutes(page); await installDemoRoutes(page);
const ctx = await prepareTimeline(page); const ctx = await prepareTimeline(page);
const timeline = await runTimeline(ctx); const timeline = await runTimeline(ctx, storyboard);
await page.close(); await page.close();
const rawPath = join(OUTPUT_DIR, 'recording.raw.webm'); const rawPath = join(OUTPUT_DIR, 'recording.raw.webm');
@ -54,6 +56,16 @@ async function main() {
recordStartMs, recordStartMs,
...timeline, ...timeline,
}); });
const totalDurationMs =
timeline.sceneEndMs - timeline.sceneStartMs + LEAD_IN_S * 1000;
const cues = narrationLog.flush(
join(OUTPUT_DIR, 'narration.json'),
totalDurationMs
);
console.log(
`Wrote ${cues.length} narration cues to ${join(OUTPUT_DIR, 'narration.json')}`
);
console.log('Run "npm run encode" to produce output/recording.mp4'); console.log('Run "npm run encode" to produce output/recording.mp4');
} }

275
video/src/runner.ts Normal file
View file

@ -0,0 +1,275 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { Page } from 'playwright';
import { LEAD_IN_S, OUTPUT_DIR } from './config.js';
import {
clearVignette,
hideCaption,
setCursorScale,
showCaption,
showOutro,
zoomReset,
zoomTo,
} from './dom.js';
import { fakeType, sleep, smoothDragSliderThumb, smoothMove } from './motion.js';
import { narrationLog } from './narration.js';
import type { Activity, Cue, ScriptCtx, Storyboard, Target } from './script.js';
export interface RunnerResult {
/** Wall-clock when the first activity started. */
sceneStartMs: number;
/** Wall-clock when the last activity finished (after padding). */
sceneEndMs: number;
}
const MAP_ZOOM_WHEEL_DELTA = -120;
const FALLBACK_MS_PER_WORD = 750;
const FALLBACK_TAIL_BUFFER_MS = 800;
interface SynthCue {
cueIndex: number;
text: string;
durationMs: number;
}
/**
* Drive the recording from a cue-anchored storyboard.
*
* Synth runs first and writes ``output/audio/index.json`` with per-cue
* measured durations. The runner reads that manifest and sizes each cue's
* wall-clock to its measured audio length: ``during`` activities run
* sequentially with their declared budgets, then a final wait pads to the
* full cue duration so the caption stays on for as long as the audio
* plays. ``tail`` activities run after the caption hides; ``gapBeforeMs``
* inserts pure silence before the next cue.
*
* The activity cursor is wall-clock honest: each step advances it by
* ``max(declared, actual)`` so an overrun extends the timeline rather than
* silently desyncing the narration manifest from reality. videoTimeMs
* recorded for each cue therefore matches the trimmed mp4 frame-for-frame,
* which is what the mux step needs to drop audio at the right moment.
*
* If the audio manifest is missing (``--no-audio`` runs), we fall back to a
* worst-case estimate (750ms/word + 800ms buffer) so the visual flow still
* works, just without sound.
*/
export async function runStoryboard(
ctx: ScriptCtx,
storyboard: Storyboard
): Promise<RunnerResult> {
narrationLog.reset();
const synth = loadSynthIndex(storyboard);
const sceneStartMs = Date.now();
const leadInMs = LEAD_IN_S * 1000;
const cursor = { ms: 0 };
for (const step of storyboard.pre ?? []) {
cursor.ms += await runStep(ctx, step);
}
for (let i = 0; i < storyboard.cues.length; i++) {
await runCue(ctx, storyboard.cues[i], synth[i], cursor, leadInMs);
}
for (const step of storyboard.post ?? []) {
cursor.ms += await runStep(ctx, step);
}
return { sceneStartMs, sceneEndMs: sceneStartMs + cursor.ms };
}
async function runCue(
ctx: ScriptCtx,
cue: Cue,
synth: SynthCue,
cursor: { ms: number },
leadInMs: number
): Promise<void> {
if (cue.gapBeforeMs > 0) {
await sleep(cue.gapBeforeMs);
cursor.ms += cue.gapBeforeMs;
}
const measuredAudioMs = synth.durationMs;
narrationLog.add({
text: cue.text,
videoTimeMs: cursor.ms + leadInMs,
durationMs: measuredAudioMs,
});
await showCaption(ctx.page, cue.text);
const during = cue.during ?? [];
const declaredSum = during.reduce((s, a) => s + a.durationMs, 0);
if (declaredSum > measuredAudioMs + 50) {
throw new Error(
`Cue ${synth.cueIndex} "${cue.text.slice(0, 40)}…" has ${declaredSum}ms of ` +
`during activities but the measured audio is only ${measuredAudioMs}ms. ` +
`Trim a during step, lengthen the cue text, or move work into tail.`
);
}
// Time the during block as a whole — individual steps may overrun their
// budgets, but what matters at the cue boundary is total wall-clock.
const duringStart = Date.now();
for (const step of during) {
await runStep(ctx, step);
}
const duringElapsed = Date.now() - duringStart;
if (duringElapsed < measuredAudioMs) {
await sleep(measuredAudioMs - duringElapsed);
cursor.ms += measuredAudioMs;
} else {
cursor.ms += duringElapsed;
}
await hideCaption(ctx.page);
for (const step of cue.tail ?? []) {
cursor.ms += await runStep(ctx, step);
}
}
/**
* Run a single activity. Pads short steps to their declared budget, lets
* long ones bleed past it, and returns ``max(declared, actual)`` so the
* caller can advance the wall-clock-honest cursor.
*/
async function runStep(ctx: ScriptCtx, step: Activity): Promise<number> {
const startedAt = Date.now();
await runActivity(ctx, step);
const realMs = Date.now() - startedAt;
if (realMs < step.durationMs) {
await sleep(step.durationMs - realMs);
return step.durationMs;
}
if (realMs > step.durationMs + 50) {
console.log(
`[runner] step ${step.kind} ran ${realMs}ms over a ${step.durationMs}ms budget (drift +${realMs - step.durationMs}ms)`
);
}
return realMs;
}
async function runActivity(ctx: ScriptCtx, step: Activity): Promise<void> {
switch (step.kind) {
case 'wait':
return;
case 'clearVignette':
await clearVignette(ctx.page);
return;
case 'zoomTo': {
const focus = await resolveTarget(ctx, step.target);
await zoomTo(ctx.page, {
scale: step.scale,
focusX: focus.x,
focusY: focus.y,
durationMs: step.durationMs,
});
return;
}
case 'zoomReset':
await zoomReset(ctx.page, step.durationMs);
return;
case 'cursorScale':
await setCursorScale(ctx.page, step.scale, step.durationMs);
return;
case 'moveCursor': {
const to = await resolveTarget(ctx, step.target);
await smoothMove(ctx.page, ctx.cursor, to, { durationMs: step.durationMs });
ctx.cursor = to;
return;
}
case 'click': {
const to = await resolveTarget(ctx, step.target);
const moveMs = Math.max(120, Math.round(step.durationMs * 0.7));
await smoothMove(ctx.page, ctx.cursor, to, { durationMs: moveMs });
ctx.cursor = to;
await ctx.page.mouse.click(to.x, to.y);
return;
}
case 'type':
await fakeType(ctx.page, step.selector, step.text, step.durationMs);
return;
case 'mapZoom': {
const point = await resolveTarget(ctx, step.target);
await ctx.page.mouse.move(point.x, point.y);
const perStepMs = Math.floor(step.durationMs / Math.max(1, step.steps));
for (let i = 0; i < step.steps; i++) {
await ctx.page.mouse.wheel(0, MAP_ZOOM_WHEEL_DELTA);
if (perStepMs > 0) await sleep(perStepMs);
}
return;
}
case 'dragSlider':
ctx.cursor = await smoothDragSliderThumb(
ctx.page,
step.thumbSelector,
step.trackSelector,
ctx.cursor,
step.toFraction,
step.durationMs
);
return;
case 'submitForm':
await ctx.page.evaluate((selector) => {
document.querySelector<HTMLFormElement>(selector)?.requestSubmit();
}, step.formSelector);
return;
case 'showOutro':
await showOutro(ctx.page, step.brand, step.tagline, step.url);
return;
}
}
async function resolveTarget(
ctx: ScriptCtx,
target: Target
): Promise<{ x: number; y: number }> {
if (target.kind === 'point') return { x: target.x, y: target.y };
if (target.kind === 'hexagon') {
const targets = await ctx.dashboard.visibleHexagonTargets(1);
if (targets.length === 0) throw new Error('No visible hexagon to target');
return { x: targets[0].x, y: targets[0].y };
}
const box = await ctx.page.locator(target.selector).boundingBox();
if (!box) throw new Error(`No bounding box for selector: ${target.selector}`);
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
}
/**
* Load synth's measured cue durations. Falls back to a worst-case estimate
* if the manifest is missing that path is only used for ``--no-audio``
* runs, where the visual flow needs to play even without speech to time
* against.
*/
function loadSynthIndex(storyboard: Storyboard): SynthCue[] {
const path = join(OUTPUT_DIR, 'audio', 'index.json');
if (existsSync(path)) {
const raw = JSON.parse(readFileSync(path, 'utf-8')) as {
items: SynthCue[];
};
const byIndex = new Map(raw.items.map((it) => [it.cueIndex, it] as const));
return storyboard.cues.map((cue, i) => {
const m = byIndex.get(i);
if (!m) {
throw new Error(
`Synth manifest is missing cue ${i} ("${cue.text.slice(0, 40)}…"). ` +
`Re-run preflight + synth so the audio matches the storyboard.`
);
}
return m;
});
}
console.log(
`[runner] no ${path} found — using worst-case fallback durations (${FALLBACK_MS_PER_WORD}ms/word + ${FALLBACK_TAIL_BUFFER_MS}ms buffer). Audio will be missing.`
);
return storyboard.cues.map((cue, cueIndex) => ({
cueIndex,
text: cue.text,
durationMs:
cue.text.split(/\s+/).filter(Boolean).length * FALLBACK_MS_PER_WORD +
FALLBACK_TAIL_BUFFER_MS,
}));
}
export type { Page };

109
video/src/script.ts Normal file
View file

@ -0,0 +1,109 @@
import type { Page } from 'playwright';
import type { DashboardRecorder } from './dashboard.js';
/**
* Public scripting API for the demo video.
*
* The storyboard is a `Storyboard` an ordered list of narration cues, each
* carrying the activities that play alongside it. Audio is generated FIRST
* (one batched Qwen call so the voice stays consistent across cues); the
* runner then reads the measured per-cue durations and slots `during`
* activities inside each cue's audio window.
*
* Why cue-anchored: the audio drives pacing. Re-running synth produces a new
* set of measured durations and the storyboard self-aligns you don't have
* to retune activity numbers. Author intent stays declarative ("zoom + type
* happen during this cue, dwell 4s after, then next cue starts").
*/
export interface ScriptCtx {
page: Page;
dashboard: DashboardRecorder;
cursor: { x: number; y: number };
}
/** A point on screen, either absolute pixel coords or the centre of an element. */
export type Target =
| { kind: 'point'; x: number; y: number }
| { kind: 'element'; selector: string }
/**
* Resolved at runtime to the centre of a visible hexagon/postcode polygon,
* picked from the dashboard's most recent map response. Robust to any zoom
* level use this when the click MUST land on a polygon and a fixed pixel
* coordinate would risk landing on a road or river at deep zoom.
*/
| { kind: 'hexagon' };
export const at = (x: number, y: number): Target => ({ kind: 'point', x, y });
export const el = (selector: string): Target => ({ kind: 'element', selector });
export const hex = (): Target => ({ kind: 'hexagon' });
/**
* Activities are the runner's atomic operations. Each one has a fixed
* `durationMs` budget; the runner pads short overruns and warns on long ones.
*/
export type Activity =
/** Pure pause. Useful for spacing. */
| { kind: 'wait'; durationMs: number }
/** Smoothly zoom the dashboard wrapper so `target` lands at viewport centre. */
| { kind: 'zoomTo'; target: Target; scale: number; durationMs: number }
/** Animate the wrapper back to identity. */
| { kind: 'zoomReset'; durationMs: number }
/** Slide the cursor from its current position to `target`. */
| { kind: 'moveCursor'; target: Target; durationMs: number }
/** Move + click + ripple. `durationMs` is the whole gesture, including settle. */
| { kind: 'click'; target: Target; durationMs: number }
/** Type into a textarea/input over exactly `durationMs`. */
| { kind: 'type'; selector: string; text: string; durationMs: number }
/** Grow or shrink the visible cursor (CSS scale). */
| { kind: 'cursorScale'; scale: number; durationMs: number }
/**
* Wheel-zoom the underlying map at `target`. `steps` controls intensity
* (each step is one ~120px wheel notch).
*/
| { kind: 'mapZoom'; target: Target; steps: number; durationMs: number }
/** Drag the right thumb of a Radix slider to a fraction in [0,1]. */
| {
kind: 'dragSlider';
thumbSelector: string;
trackSelector: string;
toFraction: number;
durationMs: number;
}
/** Submit a form found by selector and wait `durationMs`. */
| { kind: 'submitForm'; formSelector: string; durationMs: number }
/** Reveal the closing brand card. */
| { kind: 'showOutro'; brand: string; tagline: string; url: string; durationMs: number }
/** Fade away the opening vignette. */
| { kind: 'clearVignette'; durationMs: number };
/**
* A narration cue + the activities that play alongside it.
*
* gapBeforeMs : silent wall-time before the caption appears (= silence in
* audio between the previous cue ending and this one).
* during : activities that play WHILE the caption is on screen. The
* sum of declared durations must be the measured audio
* duration; the runner pads short blocks so the caption stays
* on for the full cue. Sum > measured is a hard error.
* tail : activities that run AFTER the caption hides, before the
* next cue's gapBefore starts. Use it for dwells/transitions
* that aren't tied to spoken words.
*/
export interface Cue {
text: string;
gapBeforeMs: number;
during?: Activity[];
tail?: Activity[];
}
/**
* Top-level storyboard. `pre` runs once before the first cue's gapBefore;
* `post` runs once after the last cue's tail finishes. The cue list is what
* gets handed to the synth step.
*/
export interface Storyboard {
pre?: Activity[];
cues: Cue[];
post?: Activity[];
}

170
video/src/storyboard.ts Normal file
View file

@ -0,0 +1,170 @@
import {
AI_ZOOM_SCALE,
BRAND_NAME,
BRAND_TAGLINE,
BRAND_URL,
PROMPT_TEXT,
TT_CARD_SELECTOR,
TT_DRAG_TO_MIN,
TT_SLIDER_MAX,
} from './config.js';
import { el, type Storyboard } from './script.js';
/**
* The demo video, top to bottom.
*
* Audio is generated first (one batched Qwen call), so each cue's actual
* duration is known before recording. The runner sizes each cue's wall-time
* to the measured audio length, padding short `during` blocks with a
* trailing wait. Inter-cue spacing is controlled here via `gapBeforeMs`
* (silence in audio) plus optional `tail` activities (visual movement after
* the caption hides, before the next cue's gap).
*
* Sum of `during` declared durations MUST be measured cue duration. If
* synth comes back tighter than the activities can fit, the runner throws
* with a pointer to the offending cue bump that cue's text, lengthen its
* gapBefore, or trim a during step.
*
* Reference durations (Qwen3-TTS / speaker=ryan, 2026-05-09 measured):
* cue 0 1920ms "Describe the life you want."
* cue 1 2720ms "Every matching neighbourhood, side by side."
* cue 2 2160ms "Tighten the commute to 20 minutes."
* cue 3 1840ms "Drill into a single block."
* cue 4 4480ms "Stats, listings, Street View, price history…"
* cue 5 1760ms "Take the shortlist into Excel."
* cue 6 4400ms "Perfect Postcode. Find where you actually want to live."
*/
export const storyboard: Storyboard = {
// Camera push-in to the AI box happens before the first caption — silent
// setup keeps the cold open from feeling rushed.
pre: [
{ kind: 'clearVignette', durationMs: 0 },
{ kind: 'wait', durationMs: 200 },
{
kind: 'zoomTo',
target: el('[data-tutorial="ai-filters"]'),
scale: AI_ZOOM_SCALE,
durationMs: 1300,
},
{ kind: 'wait', durationMs: 140 },
],
cues: [
// -- Scene 1: AI prompt ----------------------------------------------
// Cue 0 is short (1920ms) — caption shows alone, then typing + submit
// happen silently in the tail. The natural beat is: viewer hears the
// brief, then watches the prompt being typed.
{
text: 'Describe the life you want.',
gapBeforeMs: 0,
tail: [
{ kind: 'wait', durationMs: 140 },
{
kind: 'type',
selector: '[data-tutorial="ai-filters"] textarea',
text: PROMPT_TEXT,
durationMs: 3000,
},
{ kind: 'wait', durationMs: 140 },
{ kind: 'submitForm', formSelector: '[data-tutorial="ai-filters"] form', durationMs: 1700 },
{ kind: 'wait', durationMs: 700 },
],
},
// -- Scene 2: zoom out reveal ---------------------------------------
{
text: 'Every matching neighbourhood, side by side.',
gapBeforeMs: 400,
during: [{ kind: 'zoomReset', durationMs: 1400 }],
tail: [{ kind: 'wait', durationMs: 1200 }],
},
// -- Scene 3: travel-time slider ------------------------------------
{
text: `Tighten the commute to ${TT_DRAG_TO_MIN} minutes.`,
gapBeforeMs: 500,
during: [
{
kind: 'dragSlider',
thumbSelector: `${TT_CARD_SELECTOR} [role="slider"] >> nth=1`,
trackSelector: `${TT_CARD_SELECTOR} [data-orientation="horizontal"] >> nth=0`,
toFraction: TT_DRAG_TO_MIN / TT_SLIDER_MAX,
durationMs: 1400,
},
],
tail: [{ kind: 'wait', durationMs: 1200 }],
},
// -- Scene 4a: deep zoom into a hexagon -----------------------------
// The mapZoom barely fits (1500ms vs cue 1840ms); cursor prep happens
// earlier in this cue's during, the click + payoff dwell are in tail.
{
text: 'Drill into a single block.',
gapBeforeMs: 500,
during: [
{ kind: 'cursorScale', scale: 1.4, durationMs: 200 },
{
kind: 'mapZoom',
target: { kind: 'point', x: 1140, y: 605 },
steps: 18,
durationMs: 1500,
},
],
tail: [
// Wait for the post-zoom /api/postcodes response and a redraw
// before the click — otherwise the click can fire on a stale
// frame and miss the polygon.
{ kind: 'wait', durationMs: 1200 },
{
kind: 'click',
target: { kind: 'point', x: 1140, y: 605 },
durationMs: 700,
},
{ kind: 'cursorScale', scale: 1, durationMs: 280 },
// Linger so the climax cue lands on the right-pane reveal.
{ kind: 'wait', durationMs: 1500 },
],
},
// -- Scene 4b: right-pane payoff -----------------------------------
// 4480ms cue, no during — the camera holds on the populated right pane
// for the whole climax line. Tail dwells before the export beat.
{
text: 'Stats, listings, Street View, price history — all in one pane.',
gapBeforeMs: 0,
tail: [{ kind: 'wait', durationMs: 1200 }],
},
// -- Scene 5: export ------------------------------------------------
// 1760ms cue. zoomReset + click together fit (1700ms); 60ms padding.
{
text: 'Take the shortlist into Excel.',
gapBeforeMs: 500,
during: [
{ kind: 'zoomReset', durationMs: 900 },
{
kind: 'click',
target: el('button[title="Export to Excel"]'),
durationMs: 800,
},
],
tail: [{ kind: 'wait', durationMs: 800 }],
},
// -- Scene 6: outro -------------------------------------------------
{
text: `${BRAND_NAME}. ${BRAND_TAGLINE}`,
gapBeforeMs: 600,
during: [
{
kind: 'showOutro',
brand: BRAND_NAME,
tagline: BRAND_TAGLINE,
url: BRAND_URL,
durationMs: 0,
},
],
tail: [{ kind: 'wait', durationMs: 1500 }],
},
],
};

View file

@ -1,24 +1,19 @@
import type { Page } from 'playwright'; import type { Page } from 'playwright';
import { installCursor, installZoomWrapper } from './dom.js';
import { DashboardRecorder } from './dashboard.js'; import { DashboardRecorder } from './dashboard.js';
import { installCursor, installZoomWrapper } from './dom.js';
import { sleep } from './motion.js'; import { sleep } from './motion.js';
import { dashboardUrl } from './routes.js'; import { dashboardUrl } from './routes.js';
import { import { runStoryboard, type RunnerResult } from './runner.js';
prepareAiBox, import type { ScriptCtx, Storyboard } from './script.js';
sceneAiCloseUp,
sceneClusterClick,
sceneExportAndOutro,
sceneTravelTimeSlider,
sceneZoomOutResults,
type SceneCtx,
} from './scenes.js';
export interface TimelineResult { export type TimelineResult = RunnerResult;
sceneStartMs: number;
sceneEndMs: number;
}
export async function prepareTimeline(page: Page): Promise<SceneCtx> { /**
* Boot the dashboard, wait for the first map response, and inject the
* recording chrome (cursor, zoom wrapper, caption layer). Also opens the
* AI prompt textarea so the storyboard can begin typing immediately.
*/
export async function prepareTimeline(page: Page): Promise<ScriptCtx> {
const dashboard = new DashboardRecorder(page); const dashboard = new DashboardRecorder(page);
const initialMapVersion = dashboard.getMapDataVersion(); const initialMapVersion = dashboard.getMapDataVersion();
await page.goto(dashboardUrl(), { waitUntil: 'domcontentloaded' }); await page.goto(dashboardUrl(), { waitUntil: 'domcontentloaded' });
@ -29,33 +24,46 @@ export async function prepareTimeline(page: Page): Promise<SceneCtx> {
await page.locator('canvas').first().waitFor({ state: 'attached', timeout: 15000 }); await page.locator('canvas').first().waitFor({ state: 'attached', timeout: 15000 });
await dashboard.waitForMapSettled(initialMapVersion, 15000); await dashboard.waitForMapSettled(initialMapVersion, 15000);
await new Promise((r) => setTimeout(r, 400)); await sleep(400);
await installZoomWrapper(page); await installZoomWrapper(page);
await installCursor(page); await installCursor(page);
const ctx: SceneCtx = { page, dashboard, cursor: { x: 200, y: 240 } }; const ctx: ScriptCtx = { page, dashboard, cursor: { x: 200, y: 240 } };
await page.mouse.move(ctx.cursor.x, ctx.cursor.y); await page.mouse.move(ctx.cursor.x, ctx.cursor.y);
await prepareAiBox(ctx); await prepareAiBox(ctx);
await sleep(80); await sleep(80);
return ctx; return ctx;
} }
export async function runTimeline(ctx: SceneCtx): Promise<TimelineResult> { export async function runTimeline(
const sceneStartMs = Date.now(); ctx: ScriptCtx,
let mark = sceneStartMs; storyboard: Storyboard
): Promise<TimelineResult> {
mark = await runScene('AI close-up', mark, () => sceneAiCloseUp(ctx)); return runStoryboard(ctx, storyboard);
mark = await runScene('Zoom out', mark, () => sceneZoomOutResults(ctx));
mark = await runScene('TT slider', mark, () => sceneTravelTimeSlider(ctx));
mark = await runScene('Cluster click', mark, () => sceneClusterClick(ctx));
mark = await runScene('Export + outro', mark, () => sceneExportAndOutro(ctx));
return { sceneStartMs, sceneEndMs: mark };
} }
async function runScene(label: string, prev: number, scene: () => Promise<void>): Promise<number> { /**
await scene(); * Open the AI prompt before the timed scene starts. This is preparation
const now = Date.now(); * work, not part of the storyboard, because waiting for the textarea to
console.log(`[scene] ${label}: ${((now - prev) / 1000).toFixed(2)}s wall`); * appear has indeterminate duration.
return now; */
async function prepareAiBox(ctx: ScriptCtx): Promise<void> {
const { page } = ctx;
const aiRoot = page.locator('[data-tutorial="ai-filters"]').first();
await aiRoot.waitFor({ state: 'visible', timeout: 15000 });
const textarea = page.locator('[data-tutorial="ai-filters"] textarea');
if (!(await textarea.isVisible().catch(() => false))) {
const aiButton = aiRoot.locator('button').first();
await aiButton.waitFor({ state: 'visible', timeout: 8000 });
const btnBox = await aiButton.boundingBox();
if (btnBox) await page.mouse.click(btnBox.x + btnBox.width / 2, btnBox.y + btnBox.height / 2);
}
if (!(await textarea.isVisible().catch(() => false))) {
await page.evaluate(() => {
document.querySelector<HTMLElement>('[data-tutorial="ai-filters"] button')?.click();
});
}
await textarea.waitFor({ state: 'visible', timeout: 15000 });
await sleep(100);
} }

View file

@ -1,8 +1,6 @@
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { renameSync, statSync } from 'node:fs'; import { renameSync, statSync } from 'node:fs';
import { MAX_DURATION_S, OUTPUT_FPS, VIDEO_SIZE, WEBM_BITRATE } from './config.js'; import { LEAD_IN_S, MAX_DURATION_S, OUTPUT_FPS, VIDEO_SIZE, WEBM_BITRATE } from './config.js';
const LEAD_IN_S = 0.12;
export function trimRecording( export function trimRecording(
rawPath: string, rawPath: string,

View file

@ -10,6 +10,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"types": ["node"],
"declaration": false, "declaration": false,
"sourceMap": true "sourceMap": true
}, },

188
video/tts/mux.py Normal file
View file

@ -0,0 +1,188 @@
"""Mux per-cue WAVs into recording.mp4 at their narration offsets.
Reads two manifests:
* ``output/audio/index.json`` (synth output) per-cue WAV filename + measured
duration. Generated BEFORE recording in one batched Qwen3-TTS call.
* ``output/narration.json`` (recorder output) per-cue ``videoTimeMs`` against
the trimmed video. Generated DURING recording.
Joins them by ``cueIndex`` (index in the cue list, 1:1 between manifests),
runs ffmpeg with one ``adelay`` per cue plus a single ``amix``, copies the
video stream, and writes ``output/recording.narrated.mp4``.
Run from the ``video/`` directory after recording:
uv run --project tts python tts/mux.py
"""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import sys
from pathlib import Path
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--audio-dir", type=Path, default=Path("output/audio"))
parser.add_argument(
"--narration",
type=Path,
default=Path("output/narration.json"),
help="Per-cue videoTimeMs manifest written by the recorder.",
)
parser.add_argument("--video", type=Path, default=Path("output/recording.mp4"))
parser.add_argument(
"--out",
type=Path,
default=Path("output/recording.narrated.mp4"),
)
parser.add_argument(
"--replace",
action="store_true",
help="After muxing, atomically replace --video with --out.",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
if not shutil.which("ffmpeg"):
print("[mux] ffmpeg not on PATH", file=sys.stderr)
return 1
audio_index_path = args.audio_dir / "index.json"
if not audio_index_path.exists():
print(
f"[mux] {audio_index_path} not found; run tts/synth.py first",
file=sys.stderr,
)
return 1
if not args.narration.exists():
print(
f"[mux] {args.narration} not found; the recorder must run before mux",
file=sys.stderr,
)
return 1
if not args.video.exists():
print(f"[mux] video not found: {args.video}", file=sys.stderr)
return 1
audio_index = json.loads(audio_index_path.read_text())
audio_items = [it for it in audio_index.get("items", []) if it.get("wav")]
if not audio_items:
print("[mux] synth produced no cues; copying video unchanged", file=sys.stderr)
shutil.copyfile(args.video, args.out)
return 0
narration = json.loads(args.narration.read_text())
nar_cues = list(narration.get("cues", []))
if len(nar_cues) != len(audio_items):
print(
f"[mux] cue count mismatch: synth has {len(audio_items)} cues, "
f"recorder logged {len(nar_cues)}. Re-run preflight + synth + record.",
file=sys.stderr,
)
return 1
# Sort audio items by cueIndex so list-order matches the recorder's
# cue list (which is also in cue order). Then pair 1:1.
audio_by_index = {int(it["cueIndex"]): it for it in audio_items}
items = []
for i, nar in enumerate(nar_cues):
audio = audio_by_index.get(i)
if audio is None:
print(f"[mux] no synth wav for cue {i}", file=sys.stderr)
return 1
items.append(
{
"cueIndex": i,
"wav": audio["wav"],
"durationMs": int(audio["durationMs"]),
"videoTimeMs": int(nar["videoTimeMs"]),
"text": nar.get("text", ""),
}
)
# Refuse to mux overlapping cues — amix would silently mash voices on top
# of each other. Sort by start so the order matches what we'll actually
# play, then check that each cue ends before the next one starts.
ordered = sorted(items, key=lambda it: it["videoTimeMs"])
overlaps: list[str] = []
for prev, nxt in zip(ordered, ordered[1:]):
prev_end = prev["videoTimeMs"] + prev["durationMs"]
nxt_start = nxt["videoTimeMs"]
if prev_end > nxt_start:
overlaps.append(
f"cue {prev['cueIndex']} ends at {prev_end}ms but cue {nxt['cueIndex']} "
f"starts at {nxt_start}ms (overlap {prev_end - nxt_start}ms)"
)
if overlaps:
raise SystemExit(
"[mux] refusing to produce overlapping narration:\n - "
+ "\n - ".join(overlaps)
)
cmd: list[str] = ["ffmpeg", "-y", "-loglevel", "warning", "-i", str(args.video)]
for it in items:
cmd += ["-i", str(args.audio_dir / it["wav"])]
filter_parts: list[str] = []
mix_inputs: list[str] = []
for n, it in enumerate(items, start=1):
delay_ms = max(0, it["videoTimeMs"])
label = f"a{n}"
# adelay needs one delay per channel; "all=1" applies the same delay
# to every channel, which is what we want for mono narration.
filter_parts.append(
f"[{n}:a]aresample=async=1,adelay={delay_ms}|{delay_ms}:all=1[{label}]"
)
mix_inputs.append(f"[{label}]")
mix = (
f"{''.join(mix_inputs)}amix=inputs={len(items)}"
f":duration=longest:dropout_transition=0:normalize=0[aout]"
)
filter_complex = ";".join(filter_parts + [mix])
cmd += [
"-filter_complex",
filter_complex,
"-map",
"0:v:0",
"-map",
"[aout]",
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"192k",
"-shortest",
"-movflags",
"+faststart",
str(args.out),
]
print(f"[mux] muxing {len(items)} narration cues into {args.out}", flush=True)
result = subprocess.run(cmd)
if result.returncode != 0:
print(f"[mux] ffmpeg exited {result.returncode}", file=sys.stderr)
return result.returncode
if args.replace:
args.out.replace(args.video)
print(f"[mux] replaced {args.video} with narrated copy", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

208
video/tts/synth.py Normal file
View file

@ -0,0 +1,208 @@
"""Synthesize the full narration in ONE batched Qwen3-TTS call.
Reads ``output/narration-script.json`` (emitted by ``dist/preflight.js``) and
runs ``Qwen3TTSModel.generate_custom_voice`` with all cue texts as a single
batched list that way every cue shares the same model state, which keeps
prosody and timbre consistent across cues. Per-cue WAVs and an index manifest
go to ``output/audio/`` for the recording step (which reads measured cue
durations) and the mux step (which drops each WAV at its videoTime).
Run from the ``video/`` directory:
uv run --project tts python tts/synth.py
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
import soundfile as sf
import torch
from qwen_tts import Qwen3TTSModel
DEFAULT_MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
DEFAULT_SPEAKER = "ryan"
DEFAULT_LANGUAGE = "English"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--script",
type=Path,
default=Path("output/narration-script.json"),
help="Narration script emitted by dist/preflight.js.",
)
parser.add_argument(
"--out-dir",
type=Path,
default=Path("output/audio"),
help="Directory to write WAV files and index.json into.",
)
parser.add_argument(
"--model",
default=os.environ.get("TTS_MODEL", DEFAULT_MODEL),
)
parser.add_argument(
"--speaker",
default=os.environ.get("TTS_SPEAKER", DEFAULT_SPEAKER),
help="CustomVoice preset speaker name (use --list-speakers to enumerate).",
)
parser.add_argument(
"--language",
default=os.environ.get("TTS_LANGUAGE", DEFAULT_LANGUAGE),
)
parser.add_argument(
"--device",
default=os.environ.get("TTS_DEVICE", "cuda:0"),
)
parser.add_argument(
"--list-speakers",
action="store_true",
help="Load the model, print available speaker names, and exit.",
)
return parser.parse_args()
def load_model(model_id: str, device: str) -> Qwen3TTSModel:
dtype = torch.bfloat16 if device.startswith("cuda") else torch.float32
print(f"[synth] loading {model_id} on {device} ({dtype})", flush=True)
return Qwen3TTSModel.from_pretrained(model_id, device_map=device, dtype=dtype)
def cached_index_matches(
index_path: Path,
cues: list[dict],
speaker: str,
language: str,
) -> bool:
"""Return True iff index_path's cue list lines up with `cues` 1:1.
Compared fields: ``cueIndex``, ``text``, ``gapBeforeMs`` plus the synth
settings (``speaker``, ``language``). All cue WAV files must also exist
on disk. Mismatched length, reordered cues, or a missing WAV invalidate
the cache.
"""
if not index_path.exists():
return False
try:
cached = json.loads(index_path.read_text())
except json.JSONDecodeError:
return False
if cached.get("speaker") != speaker or cached.get("language") != language:
return False
cached_items = cached.get("items", [])
if len(cached_items) != len(cues):
return False
for live, prev in zip(cues, cached_items):
if int(live["cueIndex"]) != int(prev.get("cueIndex", -1)):
return False
if live["text"].strip() != str(prev.get("text", "")).strip():
return False
if int(live.get("gapBeforeMs", 0)) != int(prev.get("gapBeforeMs", -1)):
return False
wav = prev.get("wav")
if not wav or not (index_path.parent / wav).exists():
return False
return True
def main() -> int:
args = parse_args()
if args.list_speakers:
model = load_model(args.model, args.device)
speakers = model.get_supported_speakers()
print(json.dumps(speakers, indent=2, ensure_ascii=False))
return 0
if not args.script.exists():
print(f"[synth] script not found: {args.script}", file=sys.stderr)
return 1
script = json.loads(args.script.read_text())
cues = [c for c in script.get("items", []) if c.get("text", "").strip()]
if not cues:
print("[synth] script has no cues; nothing to generate.", file=sys.stderr)
return 1
args.out_dir.mkdir(parents=True, exist_ok=True)
# Skip generation when the existing audio matches the script — same cue
# texts and same gapBeforeMs values in the same order. Saves ~30s of GPU
# time when iterating on activity timing without changing narration.
if cached_index_matches(args.out_dir / "index.json", cues, args.speaker, args.language):
print(
f"[synth] cached audio in {args.out_dir} matches the current script — skipping generation",
flush=True,
)
return 0
model = load_model(args.model, args.device)
texts = [c["text"].strip() for c in cues]
print(f"[synth] generating {len(texts)} cues in one batched call", flush=True)
for i, t in enumerate(texts):
print(f"[synth] {i:2d}: {t}", flush=True)
# ONE batched call. generate_custom_voice handles text=List[str] natively
# and broadcasts the speaker/language across all items, so the entire
# narration is decoded in one model pass — same RNG state, same batch,
# consistent voice from cue to cue.
wavs, sr = model.generate_custom_voice(
text=texts,
language=args.language,
speaker=args.speaker,
)
if len(wavs) != len(texts):
print(
f"[synth] model returned {len(wavs)} wavs for {len(texts)} cues",
file=sys.stderr,
)
return 1
items = []
for cue, audio in zip(cues, wavs):
if hasattr(audio, "cpu"):
audio = audio.cpu().float().numpy()
wav_name = f"cue_{cue['cueIndex']:03d}.wav"
wav_path = args.out_dir / wav_name
sf.write(str(wav_path), audio, sr)
duration_ms = int(round(len(audio) * 1000 / sr))
items.append(
{
"cueIndex": cue["cueIndex"],
"text": cue["text"],
"gapBeforeMs": int(cue.get("gapBeforeMs", 0)),
"wav": wav_name,
"sampleRate": sr,
"durationMs": duration_ms,
}
)
print(
f"[synth] wrote {wav_name} {duration_ms:>5d}ms «{cue['text']}»",
flush=True,
)
out_index = {
"speaker": args.speaker,
"language": args.language,
"model": args.model,
"items": items,
}
(args.out_dir / "index.json").write_text(json.dumps(out_index, indent=2))
total_ms = sum(it["gapBeforeMs"] + it["durationMs"] for it in items)
print(
f"[synth] {len(items)} cues, {total_ms}ms of audio (incl. gaps) -> {args.out_dir}",
flush=True,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())