Tonight
This commit is contained in:
parent
28323f145e
commit
94f9c0d594
76 changed files with 3238 additions and 1230 deletions
|
|
@ -66,7 +66,11 @@ export default function FeatureBrowser({
|
|||
const filtered = useMemo(() => {
|
||||
if (!search) return availableFeatures;
|
||||
const lower = search.toLowerCase();
|
||||
return availableFeatures.filter((f) => f.name.toLowerCase().includes(lower));
|
||||
return availableFeatures.filter((f) =>
|
||||
[f.name, f.description, f.detail, f.group]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.some((value) => value.toLowerCase().includes(lower))
|
||||
);
|
||||
}, [availableFeatures, search]);
|
||||
|
||||
const grouped = useMemo(() => groupFeaturesByCategory(filtered), [filtered]);
|
||||
|
|
|
|||
|
|
@ -29,6 +29,17 @@ import {
|
|||
type TravelTimeEntry,
|
||||
travelFieldKey,
|
||||
} from '../../hooks/useTravelTime';
|
||||
import {
|
||||
SPECIFIC_CRIMES_FILTER_NAME,
|
||||
SPECIFIC_CRIME_FEATURE_NAMES,
|
||||
clampSpecificCrimeRange,
|
||||
getDefaultSpecificCrimeFeatureName,
|
||||
getSpecificCrimeFeatureName,
|
||||
getSpecificCrimeFilterMeta,
|
||||
isSpecificCrimeFeatureName,
|
||||
isSpecificCrimeFilterName,
|
||||
replaceSpecificCrimeFilterKeySelection,
|
||||
} from '../../lib/crime-filter';
|
||||
import {
|
||||
SCHOOL_FILTER_NAME,
|
||||
clampSchoolRange,
|
||||
|
|
@ -399,6 +410,210 @@ function SchoolFilterCard({
|
|||
);
|
||||
}
|
||||
|
||||
function SpecificCrimeFilterCard({
|
||||
features,
|
||||
crimeFeature,
|
||||
filters,
|
||||
activeFeature,
|
||||
dragValue,
|
||||
pinnedFeature,
|
||||
filterImpact,
|
||||
percentileScale,
|
||||
onFilterChange,
|
||||
onDragStart,
|
||||
onDragChange,
|
||||
onDragEnd,
|
||||
onTogglePin,
|
||||
onShowInfo,
|
||||
onRemove,
|
||||
}: {
|
||||
features: FeatureMeta[];
|
||||
crimeFeature: 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 specificCrimeMeta = getSpecificCrimeFilterMeta(features);
|
||||
const crimeOptions = SPECIFIC_CRIME_FEATURE_NAMES.map((name) =>
|
||||
features.find((feature) => feature.name === name)
|
||||
).filter((feature): feature is FeatureMeta => Boolean(feature));
|
||||
const selectedFeatureName =
|
||||
getSpecificCrimeFeatureName(crimeFeature.name) ?? getDefaultSpecificCrimeFeatureName(features);
|
||||
const selectedFeature = selectedFeatureName
|
||||
? features.find((feature) => feature.name === selectedFeatureName)
|
||||
: undefined;
|
||||
|
||||
if (!selectedFeature || crimeOptions.length === 0 || !selectedFeatureName) return null;
|
||||
|
||||
const isActive = activeFeature === crimeFeature.name;
|
||||
const isPinned = pinnedFeature === crimeFeature.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[crimeFeature.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 replaceCrimeFeature = (nextFeatureName: string) => {
|
||||
const nextName = replaceSpecificCrimeFilterKeySelection(crimeFeature.name, nextFeatureName);
|
||||
if (nextName === crimeFeature.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 = clampSpecificCrimeRange(
|
||||
[
|
||||
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={SPECIFIC_CRIMES_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={specificCrimeMeta}
|
||||
size="sm"
|
||||
className="min-w-0 shrink"
|
||||
hideIconOnMobile
|
||||
/>
|
||||
<FeatureActions
|
||||
feature={selectedFeature}
|
||||
actionName={crimeFeature.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">
|
||||
Crime type
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedFeatureName}
|
||||
onChange={(e) => replaceCrimeFeature(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"
|
||||
>
|
||||
{crimeOptions.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(crimeFeature.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(crimeFeature.name, clampSpecificCrimeRange(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;
|
||||
|
|
@ -492,6 +707,11 @@ export default memo(function Filters({
|
|||
|
||||
const defaultSchoolFeatureName = useMemo(() => getDefaultSchoolFeatureName(features), [features]);
|
||||
const schoolMeta = useMemo(() => getSchoolFilterMeta(features), [features]);
|
||||
const defaultSpecificCrimeFeatureName = useMemo(
|
||||
() => getDefaultSpecificCrimeFeatureName(features),
|
||||
[features]
|
||||
);
|
||||
const specificCrimeMeta = useMemo(() => getSpecificCrimeFilterMeta(features), [features]);
|
||||
const schoolFilterItems = useMemo(() => {
|
||||
return Object.keys(filters)
|
||||
.filter(isSchoolFilterName)
|
||||
|
|
@ -503,9 +723,21 @@ export default memo(function Filters({
|
|||
return { ...(backendFeature ?? schoolMeta), name, group: 'Education' };
|
||||
});
|
||||
}, [filters, features, schoolMeta]);
|
||||
const specificCrimeFilterItems = useMemo(() => {
|
||||
return Object.keys(filters)
|
||||
.filter(isSpecificCrimeFilterName)
|
||||
.map((name) => {
|
||||
const backendName = getSpecificCrimeFeatureName(name);
|
||||
const backendFeature = backendName
|
||||
? features.find((feature) => feature.name === backendName)
|
||||
: undefined;
|
||||
return { ...(backendFeature ?? specificCrimeMeta), name, group: 'Crime' };
|
||||
});
|
||||
}, [filters, features, specificCrimeMeta]);
|
||||
const availableFeatures = useMemo(() => {
|
||||
const result: FeatureMeta[] = [];
|
||||
let insertedSchoolFilter = false;
|
||||
let insertedSpecificCrimeFilter = false;
|
||||
|
||||
for (const feature of features) {
|
||||
if (isSchoolFilterName(feature.name)) {
|
||||
|
|
@ -515,14 +747,29 @@ export default memo(function Filters({
|
|||
}
|
||||
continue;
|
||||
}
|
||||
if (isSpecificCrimeFeatureName(feature.name)) {
|
||||
if (defaultSpecificCrimeFeatureName && !insertedSpecificCrimeFilter) {
|
||||
result.push(specificCrimeMeta);
|
||||
insertedSpecificCrimeFilter = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!enabledFeatures.has(feature.name)) result.push(feature);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [features, enabledFeatures, defaultSchoolFeatureName, schoolMeta]);
|
||||
}, [
|
||||
features,
|
||||
enabledFeatures,
|
||||
defaultSchoolFeatureName,
|
||||
schoolMeta,
|
||||
defaultSpecificCrimeFeatureName,
|
||||
specificCrimeMeta,
|
||||
]);
|
||||
const enabledFeatureList = useMemo(() => {
|
||||
const result: FeatureMeta[] = [];
|
||||
let insertedSchoolFilter = false;
|
||||
let insertedSpecificCrimeFilters = false;
|
||||
|
||||
for (const feature of features) {
|
||||
if (isSchoolFilterName(feature.name)) {
|
||||
|
|
@ -532,11 +779,18 @@ export default memo(function Filters({
|
|||
}
|
||||
continue;
|
||||
}
|
||||
if (isSpecificCrimeFeatureName(feature.name)) {
|
||||
if (!insertedSpecificCrimeFilters) {
|
||||
result.push(...specificCrimeFilterItems);
|
||||
insertedSpecificCrimeFilters = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (enabledFeatures.has(feature.name)) result.push(feature);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [features, enabledFeatures, schoolFilterItems]);
|
||||
}, [features, enabledFeatures, schoolFilterItems, specificCrimeFilterItems]);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -556,11 +810,17 @@ export default memo(function Filters({
|
|||
onAddFilter(SCHOOL_FILTER_NAME);
|
||||
return;
|
||||
}
|
||||
if (name === SPECIFIC_CRIMES_FILTER_NAME) {
|
||||
if (!defaultSpecificCrimeFeatureName) return;
|
||||
pendingScrollRef.current = SPECIFIC_CRIMES_FILTER_NAME;
|
||||
onAddFilter(SPECIFIC_CRIMES_FILTER_NAME);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingScrollRef.current = name;
|
||||
onAddFilter(name);
|
||||
},
|
||||
[defaultSchoolFeatureName, onAddFilter]
|
||||
[defaultSchoolFeatureName, defaultSpecificCrimeFeatureName, onAddFilter]
|
||||
);
|
||||
|
||||
const handleRemoveSchoolFilter = useCallback(
|
||||
|
|
@ -793,6 +1053,66 @@ export default memo(function Filters({
|
|||
);
|
||||
}
|
||||
|
||||
if (isSpecificCrimeFilterName(feature.name)) {
|
||||
const specificCrimeBackendName = getSpecificCrimeFeatureName(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>
|
||||
))}
|
||||
<SpecificCrimeFilterCard
|
||||
features={features}
|
||||
crimeFeature={feature}
|
||||
filters={filters}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
pinnedFeature={pinnedFeature}
|
||||
filterImpact={
|
||||
specificCrimeBackendName
|
||||
? filterImpacts?.[specificCrimeBackendName]
|
||||
: undefined
|
||||
}
|
||||
percentileScale={
|
||||
specificCrimeBackendName
|
||||
? percentileScales.get(specificCrimeBackendName)
|
||||
: 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 || [];
|
||||
|
|
@ -1063,11 +1383,13 @@ export default memo(function Filters({
|
|||
<div className="md:flex-1 md:min-h-0 md:overflow-y-auto">
|
||||
<FeatureBrowser
|
||||
availableFeatures={availableFeatures}
|
||||
allFeatures={[...features, schoolMeta]}
|
||||
allFeatures={[...features, schoolMeta, specificCrimeMeta]}
|
||||
pinnedFeature={
|
||||
pinnedFeature && isSchoolFilterName(pinnedFeature)
|
||||
? SCHOOL_FILTER_NAME
|
||||
: pinnedFeature
|
||||
: pinnedFeature && isSpecificCrimeFilterName(pinnedFeature)
|
||||
? SPECIFIC_CRIMES_FILTER_NAME
|
||||
: pinnedFeature
|
||||
}
|
||||
onAddFilter={handleAddAndScroll}
|
||||
onTogglePin={(name) => {
|
||||
|
|
@ -1075,6 +1397,10 @@ export default memo(function Filters({
|
|||
if (defaultSchoolFeatureName) onTogglePin(defaultSchoolFeatureName);
|
||||
return;
|
||||
}
|
||||
if (name === SPECIFIC_CRIMES_FILTER_NAME) {
|
||||
if (defaultSpecificCrimeFeatureName) onTogglePin(defaultSpecificCrimeFeatureName);
|
||||
return;
|
||||
}
|
||||
onTogglePin(name);
|
||||
}}
|
||||
onNavigateToSource={onNavigateToSource}
|
||||
|
|
@ -1203,7 +1529,7 @@ export default memo(function Filters({
|
|||
{clearSaveError && (
|
||||
<p className="text-sm text-red-600 dark:text-red-300">{clearSaveError}</p>
|
||||
)}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearWithoutSaving}
|
||||
|
|
@ -1214,7 +1540,7 @@ export default memo(function Filters({
|
|||
<button
|
||||
type="submit"
|
||||
disabled={!clearSaveName.trim() || savingSearch}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
|
||||
className="flex items-center justify-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
|
||||
>
|
||||
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||
{savingSearch ? t('saveSearch.saving') : t('filters.saveAndClear')}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { FeatureFilters, FeatureMeta } from '../../types';
|
|||
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';
|
||||
|
||||
interface HoverCardData {
|
||||
count: number;
|
||||
|
|
@ -42,7 +43,9 @@ export default memo(function HoverCard({
|
|||
|
||||
// Show stats for active filters (up to 4)
|
||||
for (const name of activeFilterNames.slice(0, 4)) {
|
||||
const backendName = getSchoolBackendFeatureName(name) ?? name;
|
||||
const schoolBackendName = getSchoolBackendFeatureName(name);
|
||||
const specificCrimeFeatureName = getSpecificCrimeFeatureName(name);
|
||||
const backendName = schoolBackendName ?? specificCrimeFeatureName ?? name;
|
||||
const val = data[`avg_${backendName}`] ?? data[`min_${backendName}`];
|
||||
if (val == null || typeof val !== 'number') continue;
|
||||
const meta = featureMap.get(backendName);
|
||||
|
|
@ -51,7 +54,7 @@ export default memo(function HoverCard({
|
|||
if (label) results.push({ name: backendName, value: ts(label) });
|
||||
} else {
|
||||
results.push({
|
||||
name: backendName === name ? name : SCHOOL_FILTER_NAME,
|
||||
name: schoolBackendName ? SCHOOL_FILTER_NAME : backendName,
|
||||
value: formatValue(val, meta),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { PostcodeGeometry } from '../../types';
|
||||
import type { MapFlyToOptions, PostcodeGeometry } from '../../types';
|
||||
import { authHeaders } from '../../lib/api';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
|
||||
|
|
@ -8,6 +8,8 @@ import { PlaceSearchInput } from '../ui/PlaceSearchInput';
|
|||
import { LocateIcon } from '../ui/icons/LocateIcon';
|
||||
import { SearchIcon } from '../ui/icons/SearchIcon';
|
||||
|
||||
declare const __DEV__: boolean;
|
||||
|
||||
export interface SearchedLocation {
|
||||
postcode: string;
|
||||
geometry: PostcodeGeometry;
|
||||
|
|
@ -35,13 +37,18 @@ const ZOOM_FOR_TYPE: Record<string, number> = {
|
|||
isolated_dwelling: 16,
|
||||
};
|
||||
|
||||
const DEV_CURRENT_LOCATION = {
|
||||
latitude: 51.5033635,
|
||||
longitude: -0.1276248,
|
||||
};
|
||||
|
||||
export default function LocationSearch({
|
||||
onFlyTo,
|
||||
onLocationSearched,
|
||||
onCurrentLocationFound,
|
||||
onMouseEnter,
|
||||
}: {
|
||||
onFlyTo: (lat: number, lng: number, zoom: number) => void;
|
||||
onFlyTo: (lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void;
|
||||
onLocationSearched?: (postcode: SearchedLocation | null) => void;
|
||||
onCurrentLocationFound?: (lat: number, lng: number) => void;
|
||||
onMouseEnter?: () => void;
|
||||
|
|
@ -162,7 +169,7 @@ export default function LocationSearch({
|
|||
const [locating, setLocating] = useState(false);
|
||||
|
||||
const locateUser = useCallback(async () => {
|
||||
if (!navigator.geolocation) {
|
||||
if (!__DEV__ && !navigator.geolocation) {
|
||||
setError(t('locationSearch.geolocationUnsupported'));
|
||||
return;
|
||||
}
|
||||
|
|
@ -170,15 +177,27 @@ export default function LocationSearch({
|
|||
setLocating(true);
|
||||
search.close();
|
||||
try {
|
||||
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
const { latitude, longitude } = position.coords;
|
||||
onFlyTo(latitude, longitude, 17);
|
||||
onCurrentLocationFound?.(latitude, longitude);
|
||||
const { latitude, longitude } = __DEV__
|
||||
? DEV_CURRENT_LOCATION
|
||||
: await new Promise<GeolocationCoordinates>((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error('Geolocation unsupported'));
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => resolve(position.coords),
|
||||
reject,
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
}
|
||||
);
|
||||
});
|
||||
if (onCurrentLocationFound) {
|
||||
onCurrentLocationFound(latitude, longitude);
|
||||
} else {
|
||||
onFlyTo(latitude, longitude, 17);
|
||||
}
|
||||
search.clear();
|
||||
if (isMobile) setExpanded(false);
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
POI,
|
||||
FeatureMeta,
|
||||
Bounds,
|
||||
MapFlyToOptions,
|
||||
} from '../../types';
|
||||
|
||||
import {
|
||||
|
|
@ -19,6 +20,7 @@ import {
|
|||
getBoundsFromViewState,
|
||||
getMapStyle,
|
||||
getPoiIconUrl,
|
||||
getMapCenterForTargetScreenPoint,
|
||||
} from '../../lib/map-utils';
|
||||
import {
|
||||
INITIAL_VIEW_STATE,
|
||||
|
|
@ -56,7 +58,9 @@ interface MapProps {
|
|||
onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void;
|
||||
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
|
||||
initialViewState?: ViewState;
|
||||
flyToRef?: React.MutableRefObject<((lat: number, lng: number, zoom: number) => void) | null>;
|
||||
flyToRef?: React.MutableRefObject<
|
||||
((lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void) | null
|
||||
>;
|
||||
theme?: 'light' | 'dark';
|
||||
screenshotMode?: boolean;
|
||||
ogMode?: boolean;
|
||||
|
|
@ -80,6 +84,61 @@ interface Dimensions {
|
|||
height: number;
|
||||
}
|
||||
|
||||
function resolveInset(pixelValue: number | undefined, ratioValue: number | undefined, size: number) {
|
||||
return Math.max(0, (pixelValue ?? 0) + (ratioValue ?? 0) * size);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function getMapRelativeVisibleAreaCenter(dimensions: Dimensions, options?: MapFlyToOptions) {
|
||||
const area = options?.visibleArea;
|
||||
const leftInset = resolveInset(area?.left, area?.leftRatio, dimensions.width);
|
||||
const rightInset = resolveInset(area?.right, area?.rightRatio, dimensions.width);
|
||||
const topInset = resolveInset(area?.top, area?.topRatio, dimensions.height);
|
||||
const bottomInset = resolveInset(area?.bottom, area?.bottomRatio, dimensions.height);
|
||||
|
||||
const left = Math.min(dimensions.width, leftInset);
|
||||
const right = Math.max(left, dimensions.width - Math.min(dimensions.width, rightInset));
|
||||
const top = Math.min(dimensions.height, topInset);
|
||||
const bottom = Math.max(top, dimensions.height - Math.min(dimensions.height, bottomInset));
|
||||
|
||||
return {
|
||||
x: (left + right) / 2,
|
||||
y: (top + bottom) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function getViewportRelativeVisibleAreaCenter(
|
||||
dimensions: Dimensions,
|
||||
container: HTMLDivElement | null,
|
||||
options?: MapFlyToOptions
|
||||
) {
|
||||
const area = options?.visibleViewportArea;
|
||||
if (!area || !container) return null;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const viewportLeft = resolveInset(area.left, area.leftRatio, viewportWidth);
|
||||
const viewportRight =
|
||||
viewportWidth - resolveInset(area.right, area.rightRatio, viewportWidth);
|
||||
const viewportTop = resolveInset(area.top, area.topRatio, viewportHeight);
|
||||
const viewportBottom =
|
||||
viewportHeight - resolveInset(area.bottom, area.bottomRatio, viewportHeight);
|
||||
|
||||
const left = clamp(viewportLeft - rect.left, 0, dimensions.width);
|
||||
const right = clamp(viewportRight - rect.left, left, dimensions.width);
|
||||
const top = clamp(viewportTop - rect.top, 0, dimensions.height);
|
||||
const bottom = clamp(viewportBottom - rect.top, top, dimensions.height);
|
||||
|
||||
return {
|
||||
x: (left + right) / 2,
|
||||
y: (top + bottom) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
interface DeckWithPrivateDraw {
|
||||
_drawLayers?: (
|
||||
redrawReason: string,
|
||||
|
|
@ -255,9 +314,27 @@ export default memo(function Map({
|
|||
if (screenshotMode) window.__map_idle = true;
|
||||
}, [screenshotMode]);
|
||||
|
||||
const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => {
|
||||
setInternalViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
|
||||
}, []);
|
||||
const handleFlyTo = useCallback(
|
||||
(lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => {
|
||||
setInternalViewState((prev) => {
|
||||
const targetPoint =
|
||||
getViewportRelativeVisibleAreaCenter(dimensions, containerRef.current, options) ??
|
||||
getMapRelativeVisibleAreaCenter(dimensions, options);
|
||||
const center = getMapCenterForTargetScreenPoint(
|
||||
lat,
|
||||
lng,
|
||||
zoom,
|
||||
dimensions.width,
|
||||
dimensions.height,
|
||||
targetPoint.x,
|
||||
targetPoint.y
|
||||
);
|
||||
|
||||
return { ...prev, ...center, zoom };
|
||||
});
|
||||
},
|
||||
[dimensions]
|
||||
);
|
||||
|
||||
if (flyToRef) flyToRef.current = handleFlyTo;
|
||||
|
||||
|
|
@ -361,7 +438,7 @@ export default memo(function Map({
|
|||
) : null
|
||||
) : (
|
||||
<>
|
||||
<div className="absolute top-3 left-3 right-3 z-[60] flex flex-wrap items-start justify-between gap-2 pointer-events-none">
|
||||
<div className="absolute top-3 left-3 right-3 z-20 flex flex-wrap items-start justify-between gap-2 pointer-events-none">
|
||||
{!hideLocationSearch && (
|
||||
<LocationSearch
|
||||
onFlyTo={handleFlyTo}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
ViewState,
|
||||
PostcodeGeometry,
|
||||
Property,
|
||||
MapFlyToOptions,
|
||||
} from '../../types';
|
||||
import type { SearchedLocation } from './LocationSearch';
|
||||
import type { Page } from '../ui/Header';
|
||||
|
|
@ -36,6 +37,7 @@ import { trackEvent } from '../../lib/analytics';
|
|||
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 { useLicense } from '../../hooks/useLicense';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import { MapPinIcon } from '../ui/icons/MapPinIcon';
|
||||
|
|
@ -208,7 +210,11 @@ export default function MapPage({
|
|||
handleToggleBest,
|
||||
} = useTravelTime(initialTravelTime);
|
||||
|
||||
const mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null);
|
||||
const mapFlyToRef = useRef<
|
||||
((lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void) | null
|
||||
>(null);
|
||||
const pendingCurrentLocationFlyToRef = useRef<{ lat: number; lng: number } | null>(null);
|
||||
const mobileDrawerPanelRectRef = useRef<DOMRectReadOnly | null>(null);
|
||||
|
||||
const mapData = useMapData({
|
||||
filters,
|
||||
|
|
@ -349,8 +355,11 @@ export default function MapPage({
|
|||
} = useHexagonSelection({
|
||||
filters,
|
||||
features,
|
||||
hexagonData: mapData.committedHexagonData,
|
||||
resolution: mapData.resolution,
|
||||
usePostcodeView: mapData.usePostcodeView,
|
||||
travelTimeEntries: entries,
|
||||
shareCode,
|
||||
journeyDest,
|
||||
});
|
||||
|
||||
|
|
@ -379,15 +388,44 @@ export default function MapPage({
|
|||
[handleLocationSearch, handleCloseSelection, isMobile]
|
||||
);
|
||||
|
||||
const consumePendingCurrentLocationFlyTo = useCallback((rect?: DOMRectReadOnly | null) => {
|
||||
const pending = pendingCurrentLocationFlyToRef.current;
|
||||
const panelRect = rect ?? mobileDrawerPanelRectRef.current;
|
||||
if (!pending || !panelRect) return;
|
||||
|
||||
const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
|
||||
mapFlyToRef.current?.(pending.lat, pending.lng, 17, {
|
||||
visibleViewportArea: { bottom: bottomInset },
|
||||
});
|
||||
pendingCurrentLocationFlyToRef.current = null;
|
||||
}, []);
|
||||
|
||||
const handleCurrentLocationFound = useCallback(
|
||||
(lat: number, lng: number) => {
|
||||
if (isMobile) {
|
||||
pendingCurrentLocationFlyToRef.current = { lat, lng };
|
||||
consumePendingCurrentLocationFlyTo();
|
||||
} else {
|
||||
mapFlyToRef.current?.(lat, lng, 17);
|
||||
}
|
||||
setCurrentLocation({ lat, lng });
|
||||
handleCurrentLocationSearch(lat, lng);
|
||||
if (isMobile) setMobileDrawerOpen(true);
|
||||
},
|
||||
[handleCurrentLocationSearch, isMobile]
|
||||
[consumePendingCurrentLocationFlyTo, handleCurrentLocationSearch, isMobile]
|
||||
);
|
||||
|
||||
const handleMobileDrawerPanelRectChange = useCallback((rect: DOMRectReadOnly) => {
|
||||
mobileDrawerPanelRectRef.current = rect;
|
||||
consumePendingCurrentLocationFlyTo(rect);
|
||||
}, [consumePendingCurrentLocationFlyTo]);
|
||||
|
||||
const handleMobileDrawerClose = useCallback(() => {
|
||||
pendingCurrentLocationFlyToRef.current = null;
|
||||
mobileDrawerPanelRectRef.current = null;
|
||||
setMobileDrawerOpen(false);
|
||||
}, []);
|
||||
|
||||
// For share-link recipients, "Continue with Demo" snaps back to the shared
|
||||
// coords (the area their link was meant to show), not the central-London
|
||||
// free-zone demo. Captured once on mount so a later URL rewrite by
|
||||
|
|
@ -557,12 +595,19 @@ export default function MapPage({
|
|||
|
||||
const mobileLegendMeta = useMemo(() => {
|
||||
const featureName = viewFeature
|
||||
? (getSchoolBackendFeatureName(viewFeature) ?? viewFeature)
|
||||
? (getSchoolBackendFeatureName(viewFeature) ??
|
||||
getSpecificCrimeFeatureName(viewFeature) ??
|
||||
viewFeature)
|
||||
: null;
|
||||
return featureName ? features.find((f) => f.name === featureName) || null : null;
|
||||
}, [viewFeature, features]);
|
||||
const mapViewFeature = useMemo(
|
||||
() => (viewFeature ? (getSchoolBackendFeatureName(viewFeature) ?? viewFeature) : null),
|
||||
() =>
|
||||
viewFeature
|
||||
? (getSchoolBackendFeatureName(viewFeature) ??
|
||||
getSpecificCrimeFeatureName(viewFeature) ??
|
||||
viewFeature)
|
||||
: null,
|
||||
[viewFeature]
|
||||
);
|
||||
const mobileDensityRange = useMemo((): [number, number] => {
|
||||
|
|
@ -760,7 +805,7 @@ export default function MapPage({
|
|||
onLoginRequired={onRegisterClick ?? (() => {})}
|
||||
isLicensed={user?.subscription === 'licensed'}
|
||||
onUpgradeClick={() => onNavigateTo('pricing')}
|
||||
onResetTutorial={tutorial.resetTutorial}
|
||||
onResetTutorial={!isMobile ? tutorial.resetTutorial : undefined}
|
||||
filterImpacts={filterCounts.impacts}
|
||||
onClearAll={handleClearAll}
|
||||
onSaveSearch={onSaveSearch}
|
||||
|
|
@ -914,10 +959,11 @@ export default function MapPage({
|
|||
{mobileDrawerOpen && selectedHexagon && (
|
||||
<Suspense fallback={<PaneFallback />}>
|
||||
<MobileDrawer
|
||||
onClose={() => setMobileDrawerOpen(false)}
|
||||
onClose={handleMobileDrawerClose}
|
||||
renderArea={renderAreaPane}
|
||||
renderProperties={renderPropertiesPane}
|
||||
tab={rightPaneTab}
|
||||
onPanelRectChange={handleMobileDrawerPanelRectChange}
|
||||
onTabChange={(t) => {
|
||||
if (t === 'properties') {
|
||||
handlePropertiesTabClick();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { TabButton } from '../ui/TabButton';
|
||||
|
|
@ -9,6 +9,7 @@ interface MobileDrawerProps {
|
|||
renderProperties: () => React.ReactNode;
|
||||
tab: 'area' | 'properties';
|
||||
onTabChange: (tab: 'area' | 'properties') => void;
|
||||
onPanelRectChange?: (rect: DOMRectReadOnly) => void;
|
||||
}
|
||||
|
||||
export default function MobileDrawer({
|
||||
|
|
@ -17,8 +18,30 @@ export default function MobileDrawer({
|
|||
renderProperties,
|
||||
tab,
|
||||
onTabChange,
|
||||
onPanelRectChange,
|
||||
}: MobileDrawerProps) {
|
||||
const { t } = useTranslation();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const panel = panelRef.current;
|
||||
if (!panel || !onPanelRectChange) return;
|
||||
|
||||
const reportRect = () => onPanelRectChange(panel.getBoundingClientRect());
|
||||
reportRect();
|
||||
|
||||
const observer = new ResizeObserver(reportRect);
|
||||
observer.observe(panel);
|
||||
window.addEventListener('resize', reportRect);
|
||||
window.visualViewport?.addEventListener('resize', reportRect);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener('resize', reportRect);
|
||||
window.visualViewport?.removeEventListener('resize', reportRect);
|
||||
};
|
||||
}, [onPanelRectChange]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
|
|
@ -34,7 +57,10 @@ export default function MobileDrawer({
|
|||
<div className="h-[10%] bg-black/50" onClick={onClose} />
|
||||
|
||||
{/* Panel — bottom 90% */}
|
||||
<div className="h-[90%] bg-white dark:bg-navy-950 rounded-t-2xl flex flex-col shadow-xl overflow-hidden">
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="h-[90%] bg-white dark:bg-navy-950 rounded-t-2xl flex flex-col shadow-xl overflow-hidden"
|
||||
>
|
||||
{/* Tab bar + close */}
|
||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm shrink-0">
|
||||
<TabButton
|
||||
|
|
|
|||
29
frontend/src/components/map/PriceHistoryChart.test.ts
Normal file
29
frontend/src/components/map/PriceHistoryChart.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getPriceScale } from './PriceHistoryChart';
|
||||
|
||||
describe('PriceHistoryChart scale', () => {
|
||||
it('uses a high percentile ceiling instead of the absolute max', () => {
|
||||
const scale = getPriceScale([
|
||||
...Array.from({ length: 19 }, (_, i) => ({
|
||||
year: 2000 + i,
|
||||
price: 3_000_000 + i * 10_000,
|
||||
})),
|
||||
{ year: 2025, price: 10_000_000 },
|
||||
]);
|
||||
|
||||
expect(scale.max).toBeGreaterThan(3_000_000);
|
||||
expect(scale.max).toBeLessThan(10_000_000);
|
||||
expect(Math.max(...scale.ticks)).toBe(scale.max);
|
||||
});
|
||||
|
||||
it('keeps single-value data visible with a padded domain', () => {
|
||||
const scale = getPriceScale([
|
||||
{ year: 2020, price: 2_500_000 },
|
||||
{ year: 2021, price: 2_500_000 },
|
||||
]);
|
||||
|
||||
expect(scale.min).toBeLessThan(2_500_000);
|
||||
expect(scale.max).toBeGreaterThan(2_500_000);
|
||||
});
|
||||
});
|
||||
|
|
@ -8,8 +8,15 @@ interface PriceHistoryChartProps {
|
|||
|
||||
const PADDING = { top: 8, right: 24, bottom: 20, left: 42 };
|
||||
const HEIGHT = 120;
|
||||
const PRICE_SCALE_TOP_PERCENTILE = 95;
|
||||
const priceFmt = { prefix: '£' };
|
||||
|
||||
interface PriceScale {
|
||||
min: number;
|
||||
max: number;
|
||||
ticks: number[];
|
||||
}
|
||||
|
||||
export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
|
@ -25,7 +32,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const { yearMin, yearMax, priceMin, priceMax, medians, priceTicks } = useMemo(() => {
|
||||
const { yearMin, yearMax, priceScale, medians } = useMemo(() => {
|
||||
let yMin = Infinity,
|
||||
yMax = -Infinity;
|
||||
for (const p of points) {
|
||||
|
|
@ -33,14 +40,6 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
if (p.year > yMax) yMax = p.year;
|
||||
}
|
||||
|
||||
// Use p5/p95 to clip outliers
|
||||
const sorted = points.map((p) => p.price).sort((a, b) => a - b);
|
||||
const p5 = sorted[Math.floor(sorted.length * 0.05)];
|
||||
const p95 = sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95))];
|
||||
const pRange = p95 - p5 || 1;
|
||||
const pMin = Math.max(0, p5 - pRange * 0.1);
|
||||
const pMax = p95 + pRange * 0.1;
|
||||
|
||||
// Yearly medians (robust to outliers)
|
||||
const byYear = new Map<number, number[]>();
|
||||
for (const p of points) {
|
||||
|
|
@ -73,15 +72,11 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
return { year: pt.year + 0.5, price: sum / count };
|
||||
});
|
||||
|
||||
const ticks = niceTicksForRange(pMin, pMax, 4);
|
||||
|
||||
return {
|
||||
yearMin: yMin,
|
||||
yearMax: yMax,
|
||||
priceMin: pMin,
|
||||
priceMax: pMax,
|
||||
priceScale: getPriceScale(points),
|
||||
medians: meds,
|
||||
priceTicks: ticks,
|
||||
};
|
||||
}, [points]);
|
||||
|
||||
|
|
@ -91,8 +86,8 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
|
||||
const scaleX = (year: number) => PADDING.left + ((year - yearMin) / yearRange) * plotW;
|
||||
const scaleY = (price: number) => {
|
||||
const t = (price - priceMin) / (priceMax - priceMin || 1);
|
||||
return PADDING.top + (1 - Math.max(0, Math.min(1, t))) * plotH;
|
||||
const t = (price - priceScale.min) / (priceScale.max - priceScale.min || 1);
|
||||
return PADDING.top + (1 - t) * plotH;
|
||||
};
|
||||
|
||||
// Year labels: every 5 years
|
||||
|
|
@ -107,7 +102,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
{width > 0 && (
|
||||
<svg width={width} height={HEIGHT}>
|
||||
{/* Grid lines */}
|
||||
{priceTicks.map((tick) => (
|
||||
{priceScale.ticks.map((tick) => (
|
||||
<line
|
||||
key={tick}
|
||||
x1={PADDING.left}
|
||||
|
|
@ -119,7 +114,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
/>
|
||||
))}
|
||||
|
||||
{/* Dots (clamp outliers to visible range) */}
|
||||
{/* Dots */}
|
||||
{points.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
|
|
@ -143,7 +138,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
)}
|
||||
|
||||
{/* Y-axis labels */}
|
||||
{priceTicks.map((tick) => (
|
||||
{priceScale.ticks.map((tick) => (
|
||||
<text
|
||||
key={`label-${tick}`}
|
||||
x={PADDING.left - 4}
|
||||
|
|
@ -176,6 +171,40 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
);
|
||||
}
|
||||
|
||||
export function getPriceScale(points: PricePoint[]): PriceScale {
|
||||
const prices = points
|
||||
.map((p) => p.price)
|
||||
.filter(Number.isFinite)
|
||||
.sort((a, b) => a - b);
|
||||
if (prices.length === 0) {
|
||||
return { min: 0, max: 1, ticks: [0, 1] };
|
||||
}
|
||||
|
||||
const min = prices[0];
|
||||
const scaleTop = percentile(prices, PRICE_SCALE_TOP_PERCENTILE);
|
||||
const range = scaleTop - min;
|
||||
const padding = range > 0 ? range * 0.1 : Math.max(Math.abs(scaleTop) * 0.1, 1);
|
||||
const paddedMin = Math.max(0, min - padding);
|
||||
const paddedMax = scaleTop + padding;
|
||||
const ticks = niceTicksForRange(paddedMin, paddedMax, 4);
|
||||
|
||||
return {
|
||||
min: ticks[0] ?? paddedMin,
|
||||
max: ticks[ticks.length - 1] ?? paddedMax,
|
||||
ticks,
|
||||
};
|
||||
}
|
||||
|
||||
function percentile(sorted: number[], p: number): number {
|
||||
const clamped = Math.max(0, Math.min(100, p));
|
||||
const rank = ((sorted.length - 1) * clamped) / 100;
|
||||
const lower = Math.floor(rank);
|
||||
const upper = Math.ceil(rank);
|
||||
if (lower === upper) return sorted[lower];
|
||||
const weight = rank - lower;
|
||||
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
|
||||
}
|
||||
|
||||
/** Generate ~count nice round tick values spanning [min, max]. */
|
||||
function niceTicksForRange(min: number, max: number, count: number): number[] {
|
||||
const range = max - min;
|
||||
|
|
@ -189,10 +218,17 @@ function niceTicksForRange(min: number, max: number, count: number): number[] {
|
|||
else if (normalized <= 7.5) step = 5 * magnitude;
|
||||
else step = 10 * magnitude;
|
||||
|
||||
const start =
|
||||
min >= 0 ? Math.max(0, Math.floor(min / step) * step) : Math.floor(min / step) * step;
|
||||
const end = Math.ceil(max / step) * step;
|
||||
|
||||
const ticks: number[] = [];
|
||||
const start = Math.ceil(min / step) * step;
|
||||
for (let t = start; t <= max; t += step) {
|
||||
ticks.push(t);
|
||||
for (let t = start; t <= end + step / 2; t += step) {
|
||||
ticks.push(cleanTick(t));
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
function cleanTick(value: number): number {
|
||||
return Number(value.toPrecision(12));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue