More FE changes
This commit is contained in:
parent
f114ada255
commit
a48eb945e0
48 changed files with 4127 additions and 1751 deletions
9
frontend/public/home-hex-pattern-dark.svg
Normal file
9
frontend/public/home-hex-pattern-dark.svg
Normal 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 |
Binary file not shown.
File diff suppressed because it is too large
Load diff
1143
frontend/src/components/home/ProductShowcase.tsx
Normal file
1143
frontend/src/components/home/ProductShowcase.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -7,7 +7,7 @@ export default function EnumBarChart({
|
|||
}: {
|
||||
counts: Record<string, number>;
|
||||
globalCounts?: Record<string, number>;
|
||||
featureName?: string;
|
||||
featureName: string;
|
||||
}) {
|
||||
const entries = Object.entries(counts).sort(([, countA], [, countB]) => countB - countA);
|
||||
const localTotal = entries.reduce((sum, [, c]) => sum + c, 0);
|
||||
|
|
@ -40,10 +40,8 @@ export default function EnumBarChart({
|
|||
: (count / maxCount) * 100;
|
||||
const globalWidth = hasGlobal && maxPct > 0 ? (globalPct / maxPct) * 100 : 0;
|
||||
|
||||
const overrideColor = featureName ? getEnumValueColor(featureName, label) : null;
|
||||
const barStyle = overrideColor
|
||||
? `rgb(${overrideColor[0]},${overrideColor[1]},${overrideColor[2]})`
|
||||
: undefined;
|
||||
const color = getEnumValueColor(featureName, label);
|
||||
const barStyle = `rgb(${color[0]},${color[1]},${color[2]})`;
|
||||
|
||||
return (
|
||||
<div key={label} className="flex items-center gap-2 text-xs">
|
||||
|
|
@ -58,14 +56,10 @@ export default function EnumBarChart({
|
|||
/>
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
barStyle
|
||||
? 'h-full rounded relative'
|
||||
: 'h-full bg-teal-500 dark:bg-teal-400 rounded relative'
|
||||
}
|
||||
className="h-full rounded relative"
|
||||
style={{
|
||||
width: `${localWidth}%`,
|
||||
...(barStyle ? { backgroundColor: barStyle } : {}),
|
||||
backgroundColor: barStyle,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { SearchInput } from '../ui/SearchInput';
|
|||
import { FilterIcon } from '../ui/icons';
|
||||
import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader';
|
||||
import { EmptyState } from '../ui/EmptyState';
|
||||
import type { FeatureMeta } from '../../types';
|
||||
import type { FeatureGroup, FeatureMeta } from '../../types';
|
||||
import { groupFeaturesByCategory } from '../../lib/features';
|
||||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||
import { FeatureActions } from '../ui/FeatureIcons';
|
||||
|
|
@ -35,6 +35,16 @@ interface FeatureBrowserProps {
|
|||
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({
|
||||
availableFeatures,
|
||||
allFeatures,
|
||||
|
|
@ -73,7 +83,7 @@ export default function FeatureBrowser({
|
|||
);
|
||||
}, [availableFeatures, search]);
|
||||
|
||||
const grouped = useMemo(() => groupFeaturesByCategory(filtered), [filtered]);
|
||||
const grouped = useMemo(() => moveTransportFirst(groupFeaturesByCategory(filtered)), [filtered]);
|
||||
|
||||
// When searching, expand all groups so results are visible
|
||||
const isSearching = search.length > 0;
|
||||
|
|
@ -91,14 +101,11 @@ export default function FeatureBrowser({
|
|||
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(() => {
|
||||
if (!showTravelModes) return grouped;
|
||||
if (grouped.some((g) => g.name === 'Transport')) return grouped;
|
||||
const groups = [...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;
|
||||
return [{ name: 'Transport', features: [] }, ...grouped];
|
||||
}, [grouped, showTravelModes]);
|
||||
|
||||
return (
|
||||
|
|
@ -133,26 +140,6 @@ export default function FeatureBrowser({
|
|||
</CollapsibleGroupHeader>
|
||||
{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' &&
|
||||
showTravelModes &&
|
||||
visibleModes.map((mode) => {
|
||||
|
|
@ -179,22 +166,46 @@ export default function FeatureBrowser({
|
|||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<IconButton
|
||||
onClick={() => setTravelInfoMode(mode)}
|
||||
title={t('filters.featureInfo')}
|
||||
title={t('filters.aboutData')}
|
||||
size="md"
|
||||
>
|
||||
<InfoIcon className="w-5 h-5 md:w-3.5 md:h-3.5" />
|
||||
</IconButton>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddTravelTimeEntry(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>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -40,6 +40,17 @@ import {
|
|||
isSpecificCrimeFilterName,
|
||||
replaceSpecificCrimeFilterKeySelection,
|
||||
} from '../../lib/crime-filter';
|
||||
import {
|
||||
ETHNICITIES_FILTER_NAME,
|
||||
ETHNICITY_FEATURE_NAMES,
|
||||
clampEthnicityRange,
|
||||
getDefaultEthnicityFeatureName,
|
||||
getEthnicityFeatureName,
|
||||
getEthnicityFilterMeta,
|
||||
isEthnicityFeatureName,
|
||||
isEthnicityFilterName,
|
||||
replaceEthnicityFilterKeySelection,
|
||||
} from '../../lib/ethnicity-filter';
|
||||
import {
|
||||
SCHOOL_FILTER_NAME,
|
||||
clampSchoolRange,
|
||||
|
|
@ -53,6 +64,25 @@ import {
|
|||
type SchoolPhase,
|
||||
type SchoolRating,
|
||||
} 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({
|
||||
value,
|
||||
|
|
@ -146,9 +176,10 @@ function SliderLabels({
|
|||
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 labels = displayValues || value;
|
||||
const labelFormat = feature?.suffix === '%' ? { raw, suffix: feature.suffix } : raw;
|
||||
|
||||
const minLabel = isAtMin ? 'min' : formatFilterValue(labels[0], raw);
|
||||
const maxLabel = isAtMax ? 'max' : formatFilterValue(labels[1], raw);
|
||||
const minLabel = isAtMin ? 'min' : formatFilterValue(labels[0], labelFormat);
|
||||
const maxLabel = isAtMax ? 'max' : formatFilterValue(labels[1], labelFormat);
|
||||
|
||||
// Smoothly spread labels apart as thumbs get close to prevent overlap.
|
||||
// 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 {
|
||||
features: FeatureMeta[];
|
||||
filters: FeatureFilters;
|
||||
|
|
@ -712,6 +1146,48 @@ export default memo(function Filters({
|
|||
[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(() => {
|
||||
return Object.keys(filters)
|
||||
.filter(isSchoolFilterName)
|
||||
|
|
@ -734,10 +1210,35 @@ export default memo(function Filters({
|
|||
return { ...(backendFeature ?? specificCrimeMeta), name, group: 'Crime' };
|
||||
});
|
||||
}, [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 result: FeatureMeta[] = [];
|
||||
let insertedSchoolFilter = false;
|
||||
let insertedSpecificCrimeFilter = false;
|
||||
let insertedEthnicityFilter = false;
|
||||
const insertedPoiFilters = new Set<PoiFilterName>();
|
||||
|
||||
for (const feature of features) {
|
||||
if (isSchoolFilterName(feature.name)) {
|
||||
|
|
@ -754,6 +1255,25 @@ export default memo(function Filters({
|
|||
}
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -765,11 +1285,17 @@ export default memo(function Filters({
|
|||
schoolMeta,
|
||||
defaultSpecificCrimeFeatureName,
|
||||
specificCrimeMeta,
|
||||
defaultEthnicityFeatureName,
|
||||
ethnicityMeta,
|
||||
defaultPoiFilterFeatureNames,
|
||||
poiFilterMetas,
|
||||
]);
|
||||
const enabledFeatureList = useMemo(() => {
|
||||
const result: FeatureMeta[] = [];
|
||||
let insertedSchoolFilter = false;
|
||||
let insertedSpecificCrimeFilters = false;
|
||||
let insertedEthnicityFilters = false;
|
||||
let insertedPoiDistanceFilters = false;
|
||||
|
||||
for (const feature of features) {
|
||||
if (isSchoolFilterName(feature.name)) {
|
||||
|
|
@ -786,11 +1312,32 @@ export default memo(function Filters({
|
|||
}
|
||||
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);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [features, enabledFeatures, schoolFilterItems, specificCrimeFilterItems]);
|
||||
}, [
|
||||
features,
|
||||
enabledFeatures,
|
||||
schoolFilterItems,
|
||||
specificCrimeFilterItems,
|
||||
ethnicityFilterItems,
|
||||
poiDistanceFilterItems,
|
||||
]);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -816,11 +1363,30 @@ export default memo(function Filters({
|
|||
onAddFilter(SPECIFIC_CRIMES_FILTER_NAME);
|
||||
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;
|
||||
onAddFilter(name);
|
||||
},
|
||||
[defaultSchoolFeatureName, defaultSpecificCrimeFeatureName, onAddFilter]
|
||||
[
|
||||
defaultSchoolFeatureName,
|
||||
defaultSpecificCrimeFeatureName,
|
||||
defaultEthnicityFeatureName,
|
||||
defaultPoiFilterFeatureNames,
|
||||
onAddFilter,
|
||||
]
|
||||
);
|
||||
|
||||
const handleRemoveSchoolFilter = useCallback(
|
||||
|
|
@ -857,15 +1423,8 @@ export default memo(function Filters({
|
|||
return scales;
|
||||
}, [features]);
|
||||
|
||||
// Insert travel time cards right before the first Transport feature,
|
||||
// so they visually group with their category.
|
||||
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]);
|
||||
// Keep commute controls at the top of active filters, before other Transport filters.
|
||||
const travelInsertIdx = 0;
|
||||
|
||||
const badgeCount = enabledFeatureList.length + activeEntryCount;
|
||||
|
||||
|
|
@ -920,11 +1479,7 @@ export default memo(function Filters({
|
|||
>
|
||||
<div
|
||||
className={`flex flex-col md:min-h-0 ${
|
||||
activeFilterCollapsed
|
||||
? 'md:[flex:0_0_auto]'
|
||||
: addFilterCollapsed
|
||||
? 'md:[flex:1_1_0]'
|
||||
: 'md:[flex:3_1_0]'
|
||||
activeFilterCollapsed ? 'md:[flex:0_0_auto]' : 'md:[flex:0_1_auto]'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
|
|
@ -969,10 +1524,7 @@ export default memo(function Filters({
|
|||
</button>
|
||||
|
||||
{!activeFilterCollapsed && (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="md:flex-1 md:min-h-0 md:overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
<div ref={scrollRef} className="md:min-h-0 md:overflow-y-auto overflow-x-hidden">
|
||||
<AiFilterInput
|
||||
loading={aiFilterLoading}
|
||||
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') {
|
||||
const selectedValues = (filters[feature.name] as string[]) || [];
|
||||
const allValues = feature.values || [];
|
||||
|
|
@ -1360,11 +2024,7 @@ export default memo(function Filters({
|
|||
|
||||
<div
|
||||
className={`flex flex-col md:min-h-0 border-t border-warm-200 dark:border-warm-700 ${
|
||||
addFilterCollapsed
|
||||
? 'md:[flex:0_0_auto]'
|
||||
: activeFilterCollapsed
|
||||
? 'md:[flex:1_1_0]'
|
||||
: 'md:[flex:2_1_0]'
|
||||
addFilterCollapsed && isLicensed ? 'md:[flex:0_0_auto]' : 'md:[flex:1_1_0]'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
|
|
@ -1379,65 +2039,100 @@ export default memo(function Filters({
|
|||
className="w-4 h-4 text-warm-400 dark:text-warm-500"
|
||||
/>
|
||||
</button>
|
||||
{!addFilterCollapsed && (
|
||||
<div className="md:flex-1 md:min-h-0 md:overflow-y-auto">
|
||||
<FeatureBrowser
|
||||
availableFeatures={availableFeatures}
|
||||
allFeatures={[...features, schoolMeta, specificCrimeMeta]}
|
||||
pinnedFeature={
|
||||
pinnedFeature && isSchoolFilterName(pinnedFeature)
|
||||
? SCHOOL_FILTER_NAME
|
||||
: pinnedFeature && isSpecificCrimeFilterName(pinnedFeature)
|
||||
? SPECIFIC_CRIMES_FILTER_NAME
|
||||
: pinnedFeature
|
||||
}
|
||||
onAddFilter={handleAddAndScroll}
|
||||
onTogglePin={(name) => {
|
||||
if (name === SCHOOL_FILTER_NAME) {
|
||||
if (defaultSchoolFeatureName) onTogglePin(defaultSchoolFeatureName);
|
||||
return;
|
||||
}
|
||||
if (name === SPECIFIC_CRIMES_FILTER_NAME) {
|
||||
if (defaultSpecificCrimeFeatureName) onTogglePin(defaultSpecificCrimeFeatureName);
|
||||
return;
|
||||
}
|
||||
onTogglePin(name);
|
||||
}}
|
||||
onNavigateToSource={onNavigateToSource}
|
||||
openInfoFeature={openInfoFeature}
|
||||
onClearOpenInfoFeature={onClearOpenInfoFeature}
|
||||
travelTimeEntries={travelTimeEntries}
|
||||
onAddTravelTimeEntry={handleAddTravelTimeAndScroll}
|
||||
/>
|
||||
{!isLicensed && (
|
||||
<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">
|
||||
{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>
|
||||
)}
|
||||
{(!addFilterCollapsed || !isLicensed) && (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
{!addFilterCollapsed && (
|
||||
<FeatureBrowser
|
||||
availableFeatures={availableFeatures}
|
||||
allFeatures={[
|
||||
...features,
|
||||
schoolMeta,
|
||||
specificCrimeMeta,
|
||||
ethnicityMeta,
|
||||
poiDistanceMeta,
|
||||
poiCount2KmMeta,
|
||||
poiCount5KmMeta,
|
||||
]}
|
||||
pinnedFeature={
|
||||
pinnedFeature && isSchoolFilterName(pinnedFeature)
|
||||
? SCHOOL_FILTER_NAME
|
||||
: pinnedFeature && isSpecificCrimeFilterName(pinnedFeature)
|
||||
? SPECIFIC_CRIMES_FILTER_NAME
|
||||
: pinnedFeature && isEthnicityFilterName(pinnedFeature)
|
||||
? ETHNICITIES_FILTER_NAME
|
||||
: pinnedFeature && isPoiDistanceFilterName(pinnedFeature)
|
||||
? (getPoiFilterName(pinnedFeature) ?? POI_DISTANCE_FILTER_NAME)
|
||||
: pinnedFeature
|
||||
}
|
||||
onAddFilter={handleAddAndScroll}
|
||||
onTogglePin={(name) => {
|
||||
if (name === SCHOOL_FILTER_NAME) {
|
||||
if (defaultSchoolFeatureName) onTogglePin(defaultSchoolFeatureName);
|
||||
return;
|
||||
}
|
||||
if (name === SPECIFIC_CRIMES_FILTER_NAME) {
|
||||
if (defaultSpecificCrimeFeatureName)
|
||||
onTogglePin(defaultSpecificCrimeFeatureName);
|
||||
return;
|
||||
}
|
||||
if (name === ETHNICITIES_FILTER_NAME) {
|
||||
if (defaultEthnicityFeatureName) onTogglePin(defaultEthnicityFeatureName);
|
||||
return;
|
||||
}
|
||||
if (POI_FILTER_NAMES.includes(name as PoiFilterName)) {
|
||||
const defaultPoiFeatureName =
|
||||
defaultPoiFilterFeatureNames[name as PoiFilterName];
|
||||
if (defaultPoiFeatureName) onTogglePin(defaultPoiFeatureName);
|
||||
return;
|
||||
}
|
||||
onTogglePin(name);
|
||||
}}
|
||||
onNavigateToSource={onNavigateToSource}
|
||||
openInfoFeature={openInfoFeature}
|
||||
onClearOpenInfoFeature={onClearOpenInfoFeature}
|
||||
travelTimeEntries={travelTimeEntries}
|
||||
onAddTravelTimeEntry={handleAddTravelTimeAndScroll}
|
||||
/>
|
||||
)}
|
||||
{!isLicensed && (
|
||||
<div className="mt-auto shrink-0 flex flex-col items-center px-5 pt-4 pb-0 border-t border-warm-200 dark:border-warm-700">
|
||||
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-relaxed mb-1">
|
||||
{t('filters.upgradePrompt')}
|
||||
</p>
|
||||
<p className="text-xs text-warm-400 dark:text-warm-500 text-center mb-4">
|
||||
{t('filters.oneTimeLifetime')}
|
||||
</p>
|
||||
<button
|
||||
onClick={onUpgradeClick}
|
||||
className="px-5 py-2.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
|
||||
>
|
||||
{t('filters.upgradeToFullMap')}
|
||||
</button>
|
||||
<svg
|
||||
viewBox="0 120 1600 230"
|
||||
className="w-full mt-4 block shrink-0"
|
||||
preserveAspectRatio="xMidYMax meet"
|
||||
>
|
||||
<path
|
||||
d="M0,350 C400,150 1200,150 1600,350 Z"
|
||||
className="fill-green-500 dark:fill-green-600"
|
||||
/>
|
||||
<path
|
||||
d="M100,350 C450,180 1150,180 1500,350 Z"
|
||||
fill="#000"
|
||||
opacity="0.08"
|
||||
/>
|
||||
<path
|
||||
d="M250,350 C550,220 1050,220 1350,350 Z"
|
||||
fill="#000"
|
||||
opacity="0.06"
|
||||
/>
|
||||
<image href="/house.png" x="735" y="110" width="130" height="120" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { formatValue } from '../../lib/format';
|
|||
import { ts } from '../../i18n/server';
|
||||
import { SCHOOL_FILTER_NAME, getSchoolBackendFeatureName } from '../../lib/school-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 {
|
||||
count: number;
|
||||
|
|
@ -45,7 +47,14 @@ export default memo(function HoverCard({
|
|||
for (const name of activeFilterNames.slice(0, 4)) {
|
||||
const schoolBackendName = getSchoolBackendFeatureName(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}`];
|
||||
if (val == null || typeof val !== 'number') continue;
|
||||
const meta = featureMap.get(backendName);
|
||||
|
|
@ -54,7 +63,11 @@ export default memo(function HoverCard({
|
|||
if (label) results.push({ name: backendName, value: ts(label) });
|
||||
} else {
|
||||
results.push({
|
||||
name: schoolBackendName ? SCHOOL_FILTER_NAME : backendName,
|
||||
name: schoolBackendName
|
||||
? SCHOOL_FILTER_NAME
|
||||
: poiDistanceFeatureName
|
||||
? POI_DISTANCE_FILTER_NAME
|
||||
: backendName,
|
||||
value: formatValue(val, meta),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type {
|
||||
|
|
@ -18,17 +20,12 @@ import type {
|
|||
import {
|
||||
zoomToResolution,
|
||||
getBoundsFromViewState,
|
||||
getBoundsWithBottomScreenInset,
|
||||
getMapStyle,
|
||||
getPoiIconUrl,
|
||||
getMapCenterForTargetScreenPoint,
|
||||
} from '../../lib/map-utils';
|
||||
import {
|
||||
INITIAL_VIEW_STATE,
|
||||
MAP_MIN_ZOOM,
|
||||
MAP_BOUNDS,
|
||||
POI_GROUP_COLORS,
|
||||
POI_DEFAULT_COLOR,
|
||||
} from '../../lib/consts';
|
||||
import { MAP_MIN_ZOOM, MAP_BOUNDS, POI_GROUP_COLORS } from '../../lib/consts';
|
||||
import LocationSearch, { type SearchedLocation } from './LocationSearch';
|
||||
import MapLegend from './MapLegend';
|
||||
import HoverCard from './HoverCard';
|
||||
|
|
@ -57,7 +54,7 @@ interface MapProps {
|
|||
hoveredHexagonId: string | null;
|
||||
onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void;
|
||||
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
|
||||
initialViewState?: ViewState;
|
||||
initialViewState: ViewState;
|
||||
flyToRef?: React.MutableRefObject<
|
||||
((lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void) | null
|
||||
>;
|
||||
|
|
@ -75,6 +72,7 @@ interface MapProps {
|
|||
travelTimeEntries?: TravelTimeEntry[];
|
||||
densityLabel?: string;
|
||||
totalCount?: number;
|
||||
bottomScreenInset?: number;
|
||||
}
|
||||
|
||||
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
|
||||
|
|
@ -84,6 +82,10 @@ interface Dimensions {
|
|||
height: number;
|
||||
}
|
||||
|
||||
type MapContainerStyle = CSSProperties & {
|
||||
'--map-mobile-bottom-inset'?: string;
|
||||
};
|
||||
|
||||
function resolveInset(
|
||||
pixelValue: 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({
|
||||
layers,
|
||||
getTooltip,
|
||||
|
|
@ -240,18 +263,18 @@ export default memo(function Map({
|
|||
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
|
||||
densityLabel: densityLabelProp,
|
||||
totalCount: totalCountProp,
|
||||
bottomScreenInset = 0,
|
||||
}: MapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<MapRef | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const modes = useTranslatedModes();
|
||||
const densityLabel = densityLabelProp ?? t('mapLegend.numberOfProperties');
|
||||
const [internalViewState, setInternalViewState] = useState<ViewState>(
|
||||
initialViewState || INITIAL_VIEW_STATE
|
||||
);
|
||||
const [internalViewState, setInternalViewState] = useState<ViewState>(initialViewState);
|
||||
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
|
||||
|
||||
// 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(() => {
|
||||
const container = containerRef.current;
|
||||
|
|
@ -282,17 +305,33 @@ export default memo(function Map({
|
|||
useEffect(() => {
|
||||
if (dimensions.width === 0 || dimensions.height === 0) return;
|
||||
|
||||
const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
|
||||
const resolution = zoomToResolution(viewState.zoom);
|
||||
let frame = 0;
|
||||
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({
|
||||
resolution,
|
||||
bounds,
|
||||
zoom: viewState.zoom,
|
||||
latitude: viewState.latitude,
|
||||
longitude: viewState.longitude,
|
||||
});
|
||||
}, [viewState, dimensions, onViewChange]);
|
||||
onViewChange({
|
||||
resolution,
|
||||
bounds,
|
||||
zoom: renderedViewState.zoom,
|
||||
latitude: renderedViewState.latitude,
|
||||
longitude: renderedViewState.longitude,
|
||||
});
|
||||
};
|
||||
frame = window.requestAnimationFrame(emit);
|
||||
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [viewState, dimensions, bottomScreenInset, onViewChange]);
|
||||
|
||||
const handleMove = useCallback((evt: { viewState: ViewState }) => {
|
||||
setInternalViewState((prev) => {
|
||||
|
|
@ -342,6 +381,14 @@ export default memo(function Map({
|
|||
if (flyToRef) flyToRef.current = handleFlyTo;
|
||||
|
||||
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 {
|
||||
layers,
|
||||
|
|
@ -374,8 +421,14 @@ export default memo(function Map({
|
|||
});
|
||||
|
||||
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
|
||||
ref={mapRef}
|
||||
{...viewState}
|
||||
onMove={handleMove}
|
||||
onLoad={undefined}
|
||||
|
|
@ -389,7 +442,7 @@ export default memo(function Map({
|
|||
keyboard={true}
|
||||
pitchWithRotate={false}
|
||||
minZoom={MAP_MIN_ZOOM}
|
||||
maxBounds={MAP_BOUNDS}
|
||||
maxBounds={maxBounds}
|
||||
>
|
||||
<DeckOverlay layers={layers} getTooltip={null} />
|
||||
{!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />}
|
||||
|
|
@ -486,6 +539,7 @@ export default memo(function Map({
|
|||
}
|
||||
featureName={colorFeatureMeta.name}
|
||||
theme={theme}
|
||||
suffix={colorFeatureMeta.suffix}
|
||||
raw={colorFeatureMeta.raw}
|
||||
/>
|
||||
) : null
|
||||
|
|
@ -553,7 +607,7 @@ export default memo(function Map({
|
|||
<span
|
||||
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: `rgb(${(POI_GROUP_COLORS[popupInfo.group] || POI_DEFAULT_COLOR).join(',')})`,
|
||||
backgroundColor: `rgb(${getPoiGroupColor(popupInfo.group).join(',')})`,
|
||||
}}
|
||||
/>
|
||||
{popupInfo.category}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
values,
|
||||
palette,
|
||||
|
|
@ -114,7 +130,9 @@ export default function MapLegend({
|
|||
const { t } = useTranslation();
|
||||
const isEnum = enumValues && enumValues.length > 0;
|
||||
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 gradientStyle =
|
||||
mode === 'density'
|
||||
|
|
@ -165,7 +183,7 @@ export default function MapLegend({
|
|||
</button>
|
||||
)}
|
||||
{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">
|
||||
{rangeMin}
|
||||
|
|
@ -213,7 +231,7 @@ export default function MapLegend({
|
|||
)}
|
||||
</div>
|
||||
{isEnum ? (
|
||||
<EnumSwatches values={enumValues} palette={enumPalette} />
|
||||
<EnumSwatches values={enumValues} palette={requireEnumPalette(enumPalette)} />
|
||||
) : (
|
||||
<>
|
||||
<div className="h-3 rounded" style={{ background: gradientStyle }} />
|
||||
|
|
|
|||
|
|
@ -38,10 +38,15 @@ import { canWheelScrollInsideTarget } from '../../lib/dom-scroll';
|
|||
import { INITIAL_VIEW_STATE } from '../../lib/consts';
|
||||
import { getSchoolBackendFeatureName } from '../../lib/school-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 { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import { MapPinIcon } from '../ui/icons/MapPinIcon';
|
||||
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 Filters = lazy(() => import('./Filters'));
|
||||
|
|
@ -55,7 +60,71 @@ const MapPageSelectionPane = lazy(() =>
|
|||
import('./MapPageSelectionPane').then((module) => ({ default: module.MapPageSelectionPane }))
|
||||
);
|
||||
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() {
|
||||
return (
|
||||
|
|
@ -98,8 +167,8 @@ interface MapPageProps {
|
|||
initialPostcode?: string;
|
||||
shareCode?: string;
|
||||
user?: { id: string; subscription: string; isAdmin?: boolean } | null;
|
||||
onLoginClick?: () => void;
|
||||
onRegisterClick?: () => void;
|
||||
onLoginClick: () => void;
|
||||
onRegisterClick: () => void;
|
||||
onSaveProperty?: (property: Property) => void;
|
||||
onUnsaveProperty?: (id: string) => void;
|
||||
isPropertySaved?: (address?: string, postcode?: string) => boolean;
|
||||
|
|
@ -146,11 +215,14 @@ export default function MapPage({
|
|||
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
|
||||
|
||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||
const [mobileBottomSheetHeight, setMobileBottomSheetHeight] = useState(0);
|
||||
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
||||
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
|
||||
|
||||
const [showBookmarkToast, setShowBookmarkToast] = useState(false);
|
||||
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(
|
||||
(property: Property) => {
|
||||
|
|
@ -166,6 +238,35 @@ export default function MapPage({
|
|||
const { t } = useTranslation();
|
||||
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 {
|
||||
filters,
|
||||
activeFeature,
|
||||
|
|
@ -555,10 +656,16 @@ export default function MapPage({
|
|||
]);
|
||||
|
||||
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial);
|
||||
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
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 params = new URLSearchParams({
|
||||
bounds: `${south},${west},${north},${east}`,
|
||||
|
|
@ -567,23 +674,48 @@ export default function MapPage({
|
|||
if (filterStr) params.set('filters', filterStr);
|
||||
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);
|
||||
fetch(url, authHeaders())
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = 'perfect-postcode-export.xlsx';
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
clearExportNotice();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(url, authHeaders({ signal: controller.signal }));
|
||||
if (!res.ok) throw new Error(await getExportErrorMessage(res));
|
||||
|
||||
const blob = await res.blob();
|
||||
if (blob.size === 0) throw new Error(t('header.exportEmpty'));
|
||||
|
||||
triggerExportDownload(blob, getExportFileName(res));
|
||||
trackEvent('Export');
|
||||
})
|
||||
.catch((err) => logNonAbortError('Export failed', err))
|
||||
.finally(() => setExporting(false));
|
||||
}, [mapData.bounds, filters, features, exporting]);
|
||||
showExportNotice({ kind: 'success', message: t('header.exportReady') });
|
||||
} catch (err) {
|
||||
if (!timedOut) logNonAbortError('Export failed', err);
|
||||
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(() => {
|
||||
onExportStateChange?.({ onExport: handleExport, exporting });
|
||||
|
|
@ -600,6 +732,8 @@ export default function MapPage({
|
|||
const featureName = viewFeature
|
||||
? (getSchoolBackendFeatureName(viewFeature) ??
|
||||
getSpecificCrimeFeatureName(viewFeature) ??
|
||||
getEthnicityFeatureName(viewFeature) ??
|
||||
getPoiDistanceFeatureName(viewFeature) ??
|
||||
viewFeature)
|
||||
: null;
|
||||
return featureName ? features.find((f) => f.name === featureName) || null : null;
|
||||
|
|
@ -609,6 +743,8 @@ export default function MapPage({
|
|||
viewFeature
|
||||
? (getSchoolBackendFeatureName(viewFeature) ??
|
||||
getSpecificCrimeFeatureName(viewFeature) ??
|
||||
getEthnicityFeatureName(viewFeature) ??
|
||||
getPoiDistanceFeatureName(viewFeature) ??
|
||||
viewFeature)
|
||||
: null,
|
||||
[viewFeature]
|
||||
|
|
@ -685,6 +821,28 @@ export default function MapPage({
|
|||
</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) {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
|
|
@ -805,7 +963,7 @@ export default function MapPage({
|
|||
aiFilterSummary={aiFilterSummary}
|
||||
onAiFilterSubmit={handleAiFilterSubmit}
|
||||
isLoggedIn={!!user}
|
||||
onLoginRequired={onRegisterClick ?? (() => {})}
|
||||
onLoginRequired={onRegisterClick}
|
||||
isLicensed={user?.subscription === 'licensed'}
|
||||
onUpgradeClick={() => onNavigateTo('pricing')}
|
||||
onResetTutorial={!isMobile ? tutorial.resetTutorial : undefined}
|
||||
|
|
@ -859,6 +1017,7 @@ export default function MapPage({
|
|||
featureName={mobileLegendMeta.name}
|
||||
theme={theme}
|
||||
inline
|
||||
suffix={mobileLegendMeta.suffix}
|
||||
raw={mobileLegendMeta.raw}
|
||||
/>
|
||||
);
|
||||
|
|
@ -926,21 +1085,11 @@ export default function MapPage({
|
|||
hideLegend
|
||||
hideLocationSearch={mobileDrawerOpen && !!selectedHexagon}
|
||||
travelTimeEntries={entries}
|
||||
bottomScreenInset={mobileBottomSheetHeight}
|
||||
/>
|
||||
</Suspense>
|
||||
</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
|
||||
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'}`}
|
||||
|
|
@ -955,7 +1104,10 @@ export default function MapPage({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<MobileBottomSheet legend={renderMobileLegend()}>
|
||||
<MobileBottomSheet
|
||||
legend={renderMobileLegend()}
|
||||
onCoveredHeightChange={setMobileBottomSheetHeight}
|
||||
>
|
||||
{renderFilters({ destinationDropdownPortal: false })}
|
||||
</MobileBottomSheet>
|
||||
|
||||
|
|
@ -979,13 +1131,14 @@ export default function MapPage({
|
|||
)}
|
||||
|
||||
{bookmarkToast}
|
||||
{exportToast}
|
||||
|
||||
{mapData.licenseRequired && (
|
||||
<Suspense fallback={null}>
|
||||
<UpgradeModal
|
||||
isLoggedIn={!!user}
|
||||
onLoginClick={onLoginClick ?? (() => {})}
|
||||
onRegisterClick={onRegisterClick ?? (() => {})}
|
||||
onLoginClick={onLoginClick}
|
||||
onRegisterClick={onRegisterClick}
|
||||
onStartCheckout={() => license.startCheckout()}
|
||||
onZoomToFreeZone={handleZoomToFreeZone}
|
||||
isShareReturn={!!shareReturnViewRef.current}
|
||||
|
|
@ -1015,11 +1168,14 @@ export default function MapPage({
|
|||
steps={tutorial.steps}
|
||||
run={tutorial.run}
|
||||
continuous
|
||||
showProgress
|
||||
showSkipButton
|
||||
callback={tutorial.handleCallback}
|
||||
styles={getTutorialStyles(theme)}
|
||||
disableScrolling
|
||||
onEvent={tutorial.handleCallback}
|
||||
styles={tutorialTheme.styles}
|
||||
options={{
|
||||
...tutorialTheme.options,
|
||||
buttons: ['back', 'close', 'primary', 'skip'],
|
||||
showProgress: true,
|
||||
skipScroll: true,
|
||||
}}
|
||||
locale={{ last: 'Finish' }}
|
||||
/>
|
||||
</Suspense>
|
||||
|
|
@ -1044,6 +1200,15 @@ export default function MapPage({
|
|||
</div>
|
||||
|
||||
<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 />}>
|
||||
<Map
|
||||
data={mapData.data}
|
||||
|
|
@ -1077,16 +1242,6 @@ export default function MapPage({
|
|||
totalCount={hasActiveFilters ? filterCounts.total : undefined}
|
||||
/>
|
||||
</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 */}
|
||||
<button
|
||||
data-tutorial="poi-button"
|
||||
|
|
@ -1120,13 +1275,14 @@ export default function MapPage({
|
|||
)}
|
||||
|
||||
{bookmarkToast}
|
||||
{exportToast}
|
||||
|
||||
{mapData.licenseRequired && (
|
||||
<Suspense fallback={null}>
|
||||
<UpgradeModal
|
||||
isLoggedIn={!!user}
|
||||
onLoginClick={onLoginClick ?? (() => {})}
|
||||
onRegisterClick={onRegisterClick ?? (() => {})}
|
||||
onLoginClick={onLoginClick}
|
||||
onRegisterClick={onRegisterClick}
|
||||
onStartCheckout={() => license.startCheckout()}
|
||||
onZoomToFreeZone={handleZoomToFreeZone}
|
||||
isShareReturn={!!shareReturnViewRef.current}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@ export type Page =
|
|||
| 'invites'
|
||||
| 'invite';
|
||||
|
||||
export interface HeaderExportState {
|
||||
onExport: () => void;
|
||||
exporting: boolean;
|
||||
}
|
||||
|
||||
export const PAGE_PATHS: Record<Page, string> = {
|
||||
home: '/',
|
||||
dashboard: '/dashboard',
|
||||
|
|
@ -59,13 +64,14 @@ export const PAGE_PATHS: Record<Page, string> = {
|
|||
invite: '/invite',
|
||||
};
|
||||
|
||||
const DASHBOARD_TABLET_SIDEBAR_QUERY = '(min-width: 768px) and (max-width: 1023px)';
|
||||
|
||||
export default function Header({
|
||||
activePage,
|
||||
onPageChange,
|
||||
theme,
|
||||
onToggleTheme,
|
||||
onExport,
|
||||
exporting,
|
||||
exportState,
|
||||
onSaveSearch,
|
||||
savingSearch,
|
||||
user,
|
||||
|
|
@ -78,8 +84,7 @@ export default function Header({
|
|||
onPageChange: (page: Page) => void;
|
||||
theme: 'light' | 'dark';
|
||||
onToggleTheme: () => void;
|
||||
onExport: (() => void) | null;
|
||||
exporting: boolean;
|
||||
exportState: HeaderExportState | null;
|
||||
onSaveSearch: (() => void) | null;
|
||||
savingSearch: boolean;
|
||||
user: AuthUser | null;
|
||||
|
|
@ -92,6 +97,17 @@ export default function Header({
|
|||
const [copied, setCopied] = useState(false);
|
||||
const [sharing, setSharing] = 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
|
||||
useEffect(() => {
|
||||
|
|
@ -103,10 +119,10 @@ export default function Header({
|
|||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [menuOpen]);
|
||||
|
||||
// Close menu when switching away from mobile
|
||||
// Close menu when switching away from the sidebar-capable header.
|
||||
useEffect(() => {
|
||||
if (!isMobile) setMenuOpen(false);
|
||||
}, [isMobile]);
|
||||
if (!useSidebarNav) setMenuOpen(false);
|
||||
}, [useSidebarNav]);
|
||||
|
||||
const doCopy = useCallback((text: string) => {
|
||||
copyToClipboard(text, () => {
|
||||
|
|
@ -140,7 +156,7 @@ export default function Header({
|
|||
};
|
||||
|
||||
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
|
||||
? 'bg-navy-700 text-white'
|
||||
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
|
||||
|
|
@ -161,8 +177,8 @@ export default function Header({
|
|||
</a>
|
||||
|
||||
{/* Desktop nav */}
|
||||
{!isMobile && (
|
||||
<nav className="flex items-center gap-2">
|
||||
{!useSidebarNav && (
|
||||
<nav className="top-menu flex items-center">
|
||||
<a
|
||||
href={PAGE_PATHS.dashboard}
|
||||
className={tabClass('dashboard')}
|
||||
|
|
@ -202,12 +218,12 @@ export default function Header({
|
|||
{/* Right side */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
{/* Desktop-only dashboard actions */}
|
||||
{!isMobile && activePage === 'dashboard' && (
|
||||
{!useSidebarNav && activePage === 'dashboard' && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleShare}
|
||||
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 ? (
|
||||
<>
|
||||
|
|
@ -226,20 +242,22 @@ export default function Header({
|
|||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={onExport ?? undefined}
|
||||
disabled={!onExport || exporting}
|
||||
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"
|
||||
title={t('header.exportToExcel')}
|
||||
>
|
||||
<DownloadIcon className="w-4 h-4" />
|
||||
{exporting ? t('header.exporting') : t('header.exportLabel')}
|
||||
</button>
|
||||
{exportState && (
|
||||
<button
|
||||
onClick={exportState.onExport}
|
||||
disabled={exportState.exporting}
|
||||
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" />
|
||||
{exportState.exporting ? t('header.exporting') : t('header.exportLabel')}
|
||||
</button>
|
||||
)}
|
||||
{onSaveSearch && (
|
||||
<button
|
||||
onClick={onSaveSearch}
|
||||
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 ? (
|
||||
<SpinnerIcon className="w-4 h-4 animate-spin" />
|
||||
|
|
@ -251,7 +269,7 @@ export default function Header({
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
{!isMobile && user && (
|
||||
{!useSidebarNav && user && (
|
||||
<a
|
||||
href={PAGE_PATHS.saved}
|
||||
className={tabClass('saved')}
|
||||
|
|
@ -262,7 +280,7 @@ export default function Header({
|
|||
)}
|
||||
|
||||
{/* Desktop-only auth */}
|
||||
{!isMobile && (
|
||||
{!useSidebarNav && (
|
||||
<>
|
||||
{user ? (
|
||||
<UserMenu
|
||||
|
|
@ -292,7 +310,7 @@ export default function Header({
|
|||
)}
|
||||
|
||||
{/* Mobile auth CTA (logged out only) */}
|
||||
{isMobile && !user && (
|
||||
{useSidebarNav && !user && (
|
||||
<button
|
||||
onClick={onRegisterClick}
|
||||
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) */}
|
||||
{!isMobile && <LanguageDropdown />}
|
||||
{!useSidebarNav && <LanguageDropdown />}
|
||||
|
||||
{/* Theme toggle (desktop, logged-out only — logged-in users use UserMenu) */}
|
||||
{!isMobile && !user && (
|
||||
{!useSidebarNav && !user && (
|
||||
<button
|
||||
onClick={onToggleTheme}
|
||||
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 */}
|
||||
{isMobile && (
|
||||
{useSidebarNav && (
|
||||
<button
|
||||
onClick={() => setMenuOpen(true)}
|
||||
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>
|
||||
|
||||
{/* Mobile slide-in menu */}
|
||||
{isMobile && menuOpen && (
|
||||
{useSidebarNav && menuOpen && (
|
||||
<MobileMenu
|
||||
activePage={activePage}
|
||||
onPageChange={onPageChange}
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
onExport={onExport}
|
||||
exporting={exporting}
|
||||
exportState={exportState}
|
||||
onSaveSearch={onSaveSearch}
|
||||
savingSearch={savingSearch}
|
||||
user={user}
|
||||
|
|
@ -354,7 +371,7 @@ export default function Header({
|
|||
/>
|
||||
)}
|
||||
{/* 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">
|
||||
<CheckIcon className="w-4 h-4 text-teal-400" />
|
||||
{t('common.copiedToClipboard')}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { ReactNode, MouseEvent } from 'react';
|
|||
interface IconButtonProps {
|
||||
onClick: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
title?: string;
|
||||
ariaLabel?: string;
|
||||
children: ReactNode;
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
|
|
@ -12,6 +13,7 @@ interface IconButtonProps {
|
|||
export function IconButton({
|
||||
onClick,
|
||||
title,
|
||||
ariaLabel,
|
||||
children,
|
||||
active,
|
||||
className,
|
||||
|
|
@ -24,8 +26,11 @@ export function IconButton({
|
|||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={ariaLabel ?? title}
|
||||
aria-pressed={active === undefined ? undefined : active}
|
||||
className={`${padClasses} rounded ${colorClasses} ${className || ''}`}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import type { Page } from './Header';
|
||||
import type { HeaderExportState, Page } from './Header';
|
||||
import { PAGE_PATHS } from './Header';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
import { changeLanguage as changeAppLanguage, SUPPORTED_LANGUAGES } from '../../i18n';
|
||||
|
|
@ -17,8 +17,7 @@ interface MobileMenuProps {
|
|||
onPageChange: (page: Page) => void;
|
||||
theme: 'light' | 'dark';
|
||||
onToggleTheme: () => void;
|
||||
onExport: (() => void) | null;
|
||||
exporting: boolean;
|
||||
exportState: HeaderExportState | null;
|
||||
onSaveSearch: (() => void) | null;
|
||||
savingSearch: boolean;
|
||||
user: AuthUser | null;
|
||||
|
|
@ -36,8 +35,7 @@ export default function MobileMenu({
|
|||
onPageChange,
|
||||
theme,
|
||||
onToggleTheme,
|
||||
onExport,
|
||||
exporting,
|
||||
exportState,
|
||||
onSaveSearch,
|
||||
savingSearch,
|
||||
user,
|
||||
|
|
@ -50,6 +48,9 @@ export default function MobileMenu({
|
|||
sharing,
|
||||
}: MobileMenuProps) {
|
||||
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) => (
|
||||
<a
|
||||
|
|
@ -72,7 +73,7 @@ export default function MobileMenu({
|
|||
);
|
||||
|
||||
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 && (
|
||||
<a
|
||||
|
|
@ -109,17 +110,19 @@ export default function MobileMenu({
|
|||
)}
|
||||
{sharing ? t('header.sharing') : copied ? t('common.copied') : t('common.share')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onExport?.();
|
||||
onClose();
|
||||
}}
|
||||
disabled={!onExport || exporting}
|
||||
className={dashboardActionClass}
|
||||
>
|
||||
<DownloadIcon className="w-4 h-4" />
|
||||
{exporting ? t('header.exporting') : t('header.exportLabel')}
|
||||
</button>
|
||||
{exportState && (
|
||||
<button
|
||||
onClick={() => {
|
||||
exportState.onExport();
|
||||
onClose();
|
||||
}}
|
||||
disabled={exportState.exporting}
|
||||
className={dashboardActionClass}
|
||||
>
|
||||
<DownloadIcon className="w-4 h-4" />
|
||||
{exportState.exporting ? t('header.exporting') : t('header.exportLabel')}
|
||||
</button>
|
||||
)}
|
||||
{onSaveSearch && (
|
||||
<button
|
||||
onClick={() => {
|
||||
|
|
@ -210,7 +213,21 @@ export default function MobileMenu({
|
|||
<div>
|
||||
{user ? (
|
||||
<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
|
||||
onClick={() => {
|
||||
onLogout();
|
||||
|
|
|
|||
|
|
@ -30,10 +30,10 @@ export function PillToggle({
|
|||
<button
|
||||
type="button"
|
||||
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}
|
||||
{label}
|
||||
<span className="block min-w-0 truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export function Slider({ className, ...props }: SliderProps) {
|
|||
{props.value?.map((_, i) => (
|
||||
<SliderPrimitive.Thumb
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -56,6 +56,15 @@ function distToRatios(dist: unknown): number[] {
|
|||
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({
|
||||
data,
|
||||
postcodeData,
|
||||
|
|
@ -127,9 +136,12 @@ export function useDeckLayers({
|
|||
? colorFeatureMeta.values.length
|
||||
: 0;
|
||||
|
||||
// Per-feature color palette (uses overrides when defined)
|
||||
const enumPaletteRef = useRef(getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values));
|
||||
enumPaletteRef.current = getEnumPaletteForFeature(viewFeature, colorFeatureMeta?.values);
|
||||
const enumPalette =
|
||||
viewFeature && colorFeatureMeta?.type === 'enum' && colorFeatureMeta.values
|
||||
? getEnumPaletteForFeature(viewFeature, colorFeatureMeta.values)
|
||||
: null;
|
||||
const enumPaletteRef = useRef(enumPalette);
|
||||
enumPaletteRef.current = enumPalette;
|
||||
|
||||
const countRange = useMemo(() => {
|
||||
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
|
||||
const pieProps: any = isEnum
|
||||
? {
|
||||
extensions: [new PieHexExtension(enumPaletteRef.current)],
|
||||
getCenter: (d: HexagonData) => [d.lon, d.lat],
|
||||
getRatios0: (d: HexagonData) => {
|
||||
const r = distToRatios(d[distKey]);
|
||||
return [r[0], r[1], r[2], r[3]];
|
||||
},
|
||||
getRatios1: (d: HexagonData) => {
|
||||
const r = distToRatios(d[distKey]);
|
||||
return [r[4], r[5], r[6], r[7]];
|
||||
},
|
||||
getRatios2: (d: HexagonData) => {
|
||||
const r = distToRatios(d[distKey]);
|
||||
return [r[8], r[9]];
|
||||
},
|
||||
updateTriggers: {
|
||||
getCenter: [colorTrigger, data],
|
||||
getRatios0: [colorTrigger, data],
|
||||
getRatios1: [colorTrigger, data],
|
||||
getRatios2: [colorTrigger, data],
|
||||
},
|
||||
}
|
||||
extensions: [new PieHexExtension(requireEnumPalette(enumPaletteRef.current))],
|
||||
getCenter: (d: HexagonData) => [d.lon, d.lat],
|
||||
getRatios0: (d: HexagonData) => {
|
||||
const r = distToRatios(d[distKey]);
|
||||
return [r[0], r[1], r[2], r[3]];
|
||||
},
|
||||
getRatios1: (d: HexagonData) => {
|
||||
const r = distToRatios(d[distKey]);
|
||||
return [r[4], r[5], r[6], r[7]];
|
||||
},
|
||||
getRatios2: (d: HexagonData) => {
|
||||
const r = distToRatios(d[distKey]);
|
||||
return [r[8], r[9]];
|
||||
},
|
||||
updateTriggers: {
|
||||
getCenter: [colorTrigger, data],
|
||||
getRatios0: [colorTrigger, data],
|
||||
getRatios1: [colorTrigger, data],
|
||||
getRatios2: [colorTrigger, data],
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
return new H3HexagonLayer<HexagonData>({
|
||||
|
|
@ -568,11 +580,15 @@ export function useDeckLayers({
|
|||
|
||||
const layers = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const baseLayers: any[] = usePostcodeView
|
||||
? zoom >= 16
|
||||
? [postcodeLayer, postcodeLabelsLayer, ...poiLayers]
|
||||
: [postcodeLayer, ...poiLayers]
|
||||
: [hexLayer, ...poiLayers];
|
||||
const baseLayers: any[] = [];
|
||||
if (usePostcodeView) {
|
||||
baseLayers.push(postcodeLayer);
|
||||
if (zoom >= 16) baseLayers.push(postcodeLabelsLayer);
|
||||
baseLayers.push(...poiLayers);
|
||||
} else {
|
||||
baseLayers.push(hexLayer);
|
||||
baseLayers.push(...poiLayers);
|
||||
}
|
||||
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
|
||||
if (currentLocationLayer) baseLayers.push(currentLocationLayer);
|
||||
return baseLayers;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,23 @@ import {
|
|||
getSpecificCrimeFilterKeyId,
|
||||
normalizeSpecificCrimeFilters,
|
||||
} 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 {
|
||||
initialFilters: FeatureFilters;
|
||||
|
|
@ -24,11 +41,19 @@ interface UseFiltersOptions {
|
|||
}
|
||||
|
||||
function normalizeFilters(filters: FeatureFilters): FeatureFilters {
|
||||
return normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters));
|
||||
return normalizePoiDistanceFilters(
|
||||
normalizeEthnicityFilters(normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters)))
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -85,6 +110,12 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
const specificCrimeFilterIdRef = useRef(
|
||||
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]);
|
||||
|
||||
|
|
@ -117,7 +148,15 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
const handleAddFilter = useCallback(
|
||||
(name: string) => {
|
||||
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 });
|
||||
setFilters((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.type === 'enum' && meta.values) {
|
||||
return { ...prev, [name]: [...meta.values!] };
|
||||
|
|
@ -234,6 +309,40 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
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 });
|
||||
});
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import {
|
|||
} from '../lib/api';
|
||||
import { getSchoolBackendFeatureName } from '../lib/school-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 { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
|
||||
import { type TravelTimeEntry } from './useTravelTime';
|
||||
|
|
@ -86,7 +88,11 @@ export function useMapData({
|
|||
const usePostcodeView = zoom >= POSTCODE_ZOOM_THRESHOLD;
|
||||
const getBackendFeatureName = useCallback(
|
||||
(name: string) =>
|
||||
getSchoolBackendFeatureName(name) ?? getSpecificCrimeFeatureName(name) ?? name,
|
||||
getSchoolBackendFeatureName(name) ??
|
||||
getSpecificCrimeFeatureName(name) ??
|
||||
getEthnicityFeatureName(name) ??
|
||||
getPoiDistanceFeatureName(name) ??
|
||||
name,
|
||||
[]
|
||||
);
|
||||
const dataViewFeature = useMemo(
|
||||
|
|
@ -279,9 +285,11 @@ export function useMapData({
|
|||
useEffect(() => {
|
||||
if (!bounds) {
|
||||
latestDataRequestKeyRef.current = '';
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
latestDataRequestKeyRef.current = dataRequestKey;
|
||||
setLoading(true);
|
||||
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
|
|
@ -294,7 +302,6 @@ export function useMapData({
|
|||
abortControllerRef.current = new AbortController();
|
||||
|
||||
const requestKey = dataRequestKey;
|
||||
setLoading(true);
|
||||
try {
|
||||
if (usePostcodeView) {
|
||||
const params = new URLSearchParams({ bounds: boundsParam });
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useCallback, useMemo } from 'react';
|
||||
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 JOYRIDE_ACTION_CLOSE = 'close';
|
||||
|
|
@ -18,43 +18,35 @@ export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked
|
|||
title: t('tutorial.step1Title'),
|
||||
content: t('tutorial.step1Content'),
|
||||
placement: 'right' as const,
|
||||
disableBeacon: true,
|
||||
skipBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="ai-filters"]',
|
||||
title: t('tutorial.step2Title'),
|
||||
content: t('tutorial.step2Content'),
|
||||
placement: 'right' as const,
|
||||
disableBeacon: true,
|
||||
skipBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="map"]',
|
||||
target: '[data-tutorial="map-anchor"]',
|
||||
title: t('tutorial.step3Title'),
|
||||
content: t('tutorial.step3Content'),
|
||||
placement: 'bottom' as const,
|
||||
disableBeacon: true,
|
||||
placement: 'top' as const,
|
||||
skipBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="search"]',
|
||||
title: t('tutorial.step4Title'),
|
||||
content: t('tutorial.step4Content'),
|
||||
placement: 'bottom' as const,
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="right-pane"]',
|
||||
title: t('tutorial.step5Title'),
|
||||
content: t('tutorial.step5Content'),
|
||||
placement: 'left' as const,
|
||||
disableBeacon: true,
|
||||
skipBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="poi-button"]',
|
||||
title: t('tutorial.step6Title'),
|
||||
content: t('tutorial.step6Content'),
|
||||
placement: 'left' as const,
|
||||
disableBeacon: true,
|
||||
styles: { tooltip: { transform: 'translateY(-50px)' } },
|
||||
skipBeacon: true,
|
||||
},
|
||||
],
|
||||
[t]
|
||||
|
|
@ -67,7 +59,7 @@ export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked
|
|||
|
||||
const shouldRun = run && !initialLoading && !isMobile && !blocked;
|
||||
|
||||
const handleCallback = useCallback((data: CallBackProps) => {
|
||||
const handleCallback = useCallback((data: EventData) => {
|
||||
const { status, action, type } = data;
|
||||
|
||||
if (status === JOYRIDE_STATUS_FINISHED || status === JOYRIDE_STATUS_SKIPPED) {
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'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.",
|
||||
'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':
|
||||
'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':
|
||||
"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':
|
||||
"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)':
|
||||
|
|
@ -54,17 +54,17 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'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.",
|
||||
'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':
|
||||
"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':
|
||||
"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':
|
||||
"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':
|
||||
'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':
|
||||
"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)':
|
||||
"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)':
|
||||
|
|
@ -150,11 +150,11 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'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.',
|
||||
'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':
|
||||
'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':
|
||||
'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':
|
||||
'Durchschnittlicher monatlicher Mietpreis aus dem ONS Price Index of Private Rents (PIPR), abgeglichen nach Gemeinde und Zimmeranzahl.',
|
||||
'Total floor area (sqm)':
|
||||
|
|
@ -192,17 +192,17 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'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.',
|
||||
'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':
|
||||
"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':
|
||||
"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':
|
||||
'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':
|
||||
'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':
|
||||
'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)':
|
||||
'Gewalt, Raub, Einbruch und Waffenbesitz pro 1.000 Einwohner pro Jahr im LSOA. Verwendet police.uk-Kriminalitätsdaten auf Straßenebene (2023–2025) 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)':
|
||||
|
|
@ -288,11 +288,11 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'Last known price':
|
||||
'来自英国土地注册局价格数据中该房产最近一次记录的成交价格。涵盖英格兰地区的住宅销售。若该房产近期未出售,数据可能已有数年之久。',
|
||||
'Estimated current price':
|
||||
'基于最后一次成交价格,使用重复销售指数(按邮政编码区段和房产类型追踪)调整当地房价随时间的变化。若EPC记录显示售后有改造记录,则会增加装修溢价。近期销售与原价接近;较早的销售调整幅度更大。',
|
||||
'基于最后一次成交价格、本地重复销售价格走势,以及附近近期成交房产。重复销售指数按邮政编码区段和房产类型追踪;在数据较少时会进行平滑并结合邻近成交样本。近期成交会接近记录价格;较早成交更依赖模型。',
|
||||
'Price per sqm':
|
||||
'用最后已知成交价除以EPC证书中的总建筑面积计算得出。便于比较不同面积房产的价值。仅在价格和面积数据均存在时才可用。',
|
||||
'Est. price per sqm':
|
||||
'用经通胀调整的估算当前价格(含装修溢价)除以EPC证书中的总建筑面积计算得出。与历史成交价格每平方米相比,提供更为最新的单位面积价格对比。',
|
||||
'用模型估算的当前价格除以EPC证书中的总建筑面积计算得出。与历史成交价格每平方米相比,提供更为最新的单位面积价格对比。',
|
||||
'Estimated monthly rent':
|
||||
'来自ONS私人租赁价格指数(PIPR)的平均月租金,按地方政府和卧室数量匹配。',
|
||||
'Total floor area (sqm)':
|
||||
|
|
@ -330,17 +330,17 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'Outstanding secondary schools within 5km':
|
||||
'5km范围内Ofsted评级为"Outstanding"的公立中学数量。尚未接受评估的学校不计入。',
|
||||
'Education, Skills and Training Score':
|
||||
'来自英格兰剥夺指数(取反后越高越好)。涵盖学校成绩、高等教育入学率、成人学历和英语水平。分数越高表示剥夺程度越低。',
|
||||
'来自英格兰剥夺指数,转换为全国百分位:0%表示最贫困,100%表示最不贫困。涵盖学校成绩、高等教育入学率、成人学历和英语水平。',
|
||||
'Income Score':
|
||||
'来自英格兰剥夺指数(取反后越高越好)。数值越高表示收入剥夺程度越低。基于收入支持、基于收入的求职者津贴、基于收入的就业与支持津贴、养老金补贴、工作税收抵免和子女税收抵免、普惠信用以及寻求庇护者等数据。',
|
||||
'来自英格兰剥夺指数,转换为全国百分位:0%表示收入剥夺最严重,100%表示收入剥夺最轻。基于收入支持、基于收入的求职者津贴、基于收入的就业与支持津贴、养老金补贴、工作税收抵免和子女税收抵免、普惠信用以及寻求庇护者等数据。',
|
||||
'Employment Score':
|
||||
'来自英格兰剥夺指数(取反后越高越好)。数值越高表示就业剥夺程度越低。基于求职者津贴、就业与支持津贴、丧失劳动能力津贴、严重残疾津贴、护理者津贴申领者及相关普惠信用申领者等数据。',
|
||||
'来自英格兰剥夺指数,转换为全国百分位:0%表示就业剥夺最严重,100%表示就业剥夺最轻。基于求职者津贴、就业与支持津贴、丧失劳动能力津贴、严重残疾津贴、护理者津贴申领者及相关普惠信用申领者等数据。',
|
||||
'Health Deprivation and Disability Score':
|
||||
'来自英格兰剥夺指数(取反后越高越好)。分数越高表示过早死亡风险越低、生活质量越好。来源于潜在寿命损失年、比较疾病和残疾率、急性发病率以及情绪和焦虑障碍等指标。',
|
||||
'来自英格兰剥夺指数,转换为全国百分位:0%表示健康剥夺最严重,100%表示健康剥夺最轻。来源于潜在寿命损失年、比较疾病和残疾率、急性发病率以及情绪和焦虑障碍等指标。',
|
||||
'Housing Conditions Score':
|
||||
'来自英格兰剥夺指数的居住环境领域(取反后越高越好)。衡量住房存量质量:中央供暖覆盖率、住房状况以及Decent Homes标准。分数越高表示住房条件越好。',
|
||||
'来自英格兰剥夺指数的居住环境领域,转换为全国百分位:0%表示条件最差,100%表示条件最好。衡量住房存量质量:中央供暖覆盖率、住房状况以及Decent Homes标准。',
|
||||
'Air Quality and Road Safety Score':
|
||||
'来自英格兰剥夺指数的居住环境领域(取反后越高越好)。通过空气质量指标以及涉及行人和骑行者的道路交通事故伤亡人数衡量室外生活环境质量。分数越高表示室外环境越好。',
|
||||
'来自英格兰剥夺指数的居住环境领域,转换为全国百分位:0%表示条件最差,100%表示条件最好。通过空气质量指标以及涉及行人和骑行者的道路交通事故伤亡人数衡量室外生活环境质量。',
|
||||
'Serious crime per 1k residents (avg/yr)':
|
||||
'LSOA内每1,000名常住居民每年发生的暴力、抢劫、入室盗窃和持有武器犯罪数量。使用police.uk街道级犯罪数据(2023-2025年)和Census 2021人口数据。按人口密度标准化,便于不同规模地区之间的比较。',
|
||||
'Minor crime per 1k residents (avg/yr)':
|
||||
|
|
@ -420,11 +420,11 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'Last known price':
|
||||
'इस संपत्ति की अंतिम दर्ज बिक्री कीमत HM Land Registry Price Paid डेटा से आती है. यह England की आवासीय बिक्री को कवर करती है. अगर संपत्ति हाल में नहीं बिकी है तो यह कीमत कई साल पुरानी हो सकती है.',
|
||||
'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':
|
||||
'अंतिम ज्ञात बिक्री कीमत को EPC प्रमाणपत्र में दर्ज कुल फर्श क्षेत्र से भाग देकर निकाला गया. अलग-अलग आकार की संपत्तियों की मूल्य तुलना के लिए उपयोगी. केवल तब उपलब्ध जब कीमत और फर्श क्षेत्र, दोनों डेटा मौजूद हों.',
|
||||
'Est. price per sqm':
|
||||
'मुद्रास्फीति-समायोजित अनुमानित मौजूदा कीमत (किसी नवीनीकरण प्रीमियम सहित) को EPC प्रमाणपत्र में दर्ज कुल फर्श क्षेत्र से भाग देकर निकाला गया. ऐतिहासिक बिक्री कीमत पर आधारित प्रति वर्ग मी कीमत की तुलना में ज्यादा ताजा कीमत/क्षेत्र तुलना देता है.',
|
||||
'मॉडल से अनुमानित मौजूदा कीमत को EPC प्रमाणपत्र में दर्ज कुल फर्श क्षेत्र से भाग देकर निकाला गया. ऐतिहासिक बिक्री कीमत पर आधारित प्रति वर्ग मी कीमत की तुलना में ज्यादा ताजा कीमत/क्षेत्र तुलना देता है.',
|
||||
'Estimated monthly rent':
|
||||
'ONS Price Index of Private Rents (PIPR) से औसत मासिक किराया, जिसे स्थानीय प्राधिकरण और बेडरूम संख्या से मिलाया गया है.',
|
||||
'Total floor area (sqm)':
|
||||
|
|
@ -462,17 +462,17 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'Outstanding secondary schools within 5km':
|
||||
'5km के भीतर state-funded secondary schools जिनकी current Ofsted rating Outstanding है. जिन schools का अभी inspection नहीं हुआ, वे excluded हैं.',
|
||||
'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':
|
||||
'English Indices of Deprivation से लिया गया (invert किया गया ताकि higher = better). Higher values कम income deprivation दिखाते हैं. Income support, income-based Jobseeker’s 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 Jobseeker’s Allowance, income-based Employment and Support Allowance, Pension Credit, Working and Child Tax Credit, Universal Credit और asylum seekers पर आधारित.',
|
||||
'Employment Score':
|
||||
'English Indices of Deprivation से लिया गया (invert किया गया ताकि higher = better). Higher values कम employment deprivation दिखाते हैं. Jobseeker’s Allowance, Employment and Support Allowance, Incapacity Benefit, Severe Disablement Allowance, Carer’s Allowance और relevant Universal Credit claimants पर आधारित.',
|
||||
'English Indices of Deprivation से लिया गया और national percentile में बदला गया: 0% सबसे अधिक employment deprived, 100% सबसे कम employment deprived. Jobseeker’s Allowance, Employment and Support Allowance, Incapacity Benefit, Severe Disablement Allowance, Carer’s Allowance और relevant Universal Credit claimants पर आधारित.',
|
||||
'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':
|
||||
'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':
|
||||
'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)':
|
||||
'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)':
|
||||
|
|
@ -558,11 +558,11 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'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.',
|
||||
'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':
|
||||
'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':
|
||||
'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':
|
||||
'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)':
|
||||
|
|
@ -600,17 +600,17 @@ export const details: Record<string, Record<string, string>> = {
|
|||
'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.',
|
||||
'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':
|
||||
'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':
|
||||
'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':
|
||||
'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':
|
||||
'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':
|
||||
'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)':
|
||||
'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 (2023–2025) é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)':
|
||||
|
|
|
|||
|
|
@ -66,6 +66,10 @@ h3 {
|
|||
}
|
||||
|
||||
.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;
|
||||
background: linear-gradient(180deg, #f3efe8 0%, #fafaf9 36%, #eef7f3 100%);
|
||||
}
|
||||
|
|
@ -82,17 +86,14 @@ h3 {
|
|||
.home-content-surface::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -120vh -120vw;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(rgba(20, 184, 166, 0.11) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(20, 184, 166, 0.11) 1px, transparent 1px);
|
||||
background-size:
|
||||
56px 56px,
|
||||
56px 56px;
|
||||
transform: skew(20deg, -15deg);
|
||||
transform-origin: center;
|
||||
background-image: var(--home-hex-pattern);
|
||||
background-position: center calc(var(--home-scroll-y, 0px) * 0.14);
|
||||
background-size: 138px 159px;
|
||||
opacity: 0.14;
|
||||
will-change: background-position;
|
||||
}
|
||||
|
||||
.home-content-surface::after {
|
||||
|
|
@ -101,21 +102,52 @@ h3 {
|
|||
inset: 0;
|
||||
z-index: 1;
|
||||
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 {
|
||||
--home-hex-pattern: url("/home-hex-pattern-dark.svg");
|
||||
background: linear-gradient(180deg, #121827 0%, #0a0e1a 42%, #10211f 100%);
|
||||
}
|
||||
|
||||
.dark .home-content-surface::before {
|
||||
background:
|
||||
linear-gradient(rgba(45, 212, 191, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(45, 212, 191, 0.1) 1px, transparent 1px);
|
||||
opacity: 0.11;
|
||||
}
|
||||
|
||||
.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 */
|
||||
|
|
@ -175,12 +207,12 @@ h3 {
|
|||
max-width: 42rem;
|
||||
}
|
||||
|
||||
.home-hero-showcase {
|
||||
.product-showcase {
|
||||
max-width: none;
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.home-hero-showcase-frame {
|
||||
.product-showcase-frame {
|
||||
height: 40rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -195,12 +227,12 @@ h3 {
|
|||
max-width: 45rem;
|
||||
}
|
||||
|
||||
.home-hero-showcase-frame {
|
||||
.product-showcase-frame {
|
||||
height: 40rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) and (min-height: 900px) {
|
||||
@media (min-width: 1200px) and (min-height: 900px) {
|
||||
.hero-roomy-lift {
|
||||
transform: translateY(-2.5rem);
|
||||
}
|
||||
|
|
@ -328,6 +360,10 @@ h3 {
|
|||
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 */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ describe('format utilities', () => {
|
|||
expect(formatFilterValue(1250)).toBe('1.3k');
|
||||
expect(formatFilterValue(1_250_000)).toBe('1.3M');
|
||||
expect(formatFilterValue(1250, true)).toBe('1250');
|
||||
expect(formatFilterValue(87.4, { raw: true, suffix: '%' })).toBe('87%');
|
||||
expect(formatTransactionDate(2024.5)).toBe('Jul 2024');
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue