UI changes
This commit is contained in:
parent
0aba73a2a3
commit
8616837c01
13 changed files with 126 additions and 100 deletions
|
|
@ -130,12 +130,7 @@ export default function FeatureBrowser({
|
||||||
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"
|
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">
|
<div className="min-w-0 mr-2">
|
||||||
<FeatureLabel feature={f} size="sm" />
|
<FeatureLabel feature={f} size="sm" description={f.description} />
|
||||||
{f.description && (
|
|
||||||
<span className="text-xs text-warm-400 dark:text-warm-500 truncate block">
|
|
||||||
{f.description}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<FeatureActions
|
<FeatureActions
|
||||||
feature={f}
|
feature={f}
|
||||||
|
|
@ -174,15 +169,16 @@ export default function FeatureBrowser({
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => setTravelInfoMode(mode)}
|
onClick={() => setTravelInfoMode(mode)}
|
||||||
title="Feature info"
|
title="Feature info"
|
||||||
|
size="md"
|
||||||
>
|
>
|
||||||
<InfoIcon className="w-3.5 h-3.5" />
|
<InfoIcon className="w-5 h-5 md:w-3.5 md:h-3.5" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<button
|
<button
|
||||||
onClick={() => onAddTravelTimeEntry(mode)}
|
onClick={() => onAddTravelTimeEntry(mode)}
|
||||||
title={`Add ${MODE_LABELS[mode]} travel time`}
|
title={`Add ${MODE_LABELS[mode]} travel time`}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<PlusIcon className="w-7 h-7 md:w-5 md:h-5" strokeWidth={2.5} />
|
<PlusIcon className="w-5 h-5 md:w-5 md:h-5" strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -112,8 +112,8 @@ function SliderLabels({
|
||||||
onValueChange?: (v: [number, number]) => void;
|
onValueChange?: (v: [number, number]) => void;
|
||||||
}) {
|
}) {
|
||||||
const range = max - min || 1;
|
const range = max - min || 1;
|
||||||
const leftPct = ((value[0] - min) / range) * 100;
|
const leftPct = Math.max(0, Math.min(100, ((value[0] - min) / range) * 100));
|
||||||
const rightPct = ((value[1] - min) / range) * 100;
|
const rightPct = Math.max(0, Math.min(100, ((value[1] - min) / range) * 100));
|
||||||
const labels = displayValues || value;
|
const labels = displayValues || value;
|
||||||
|
|
||||||
const minLabel = isAtMin ? 'min' : formatFilterValue(labels[0], raw);
|
const minLabel = isAtMin ? 'min' : formatFilterValue(labels[0], raw);
|
||||||
|
|
@ -133,7 +133,7 @@ function SliderLabels({
|
||||||
<EditableLabel
|
<EditableLabel
|
||||||
value={labels[0]}
|
value={labels[0]}
|
||||||
formatted={minLabel}
|
formatted={minLabel}
|
||||||
onCommit={(v) => onValueChange([Math.min(v, labels[1]), labels[1]])}
|
onCommit={(v) => onValueChange([v, Math.max(v, labels[1])])}
|
||||||
prefix={feature.prefix}
|
prefix={feature.prefix}
|
||||||
suffix={feature.suffix}
|
suffix={feature.suffix}
|
||||||
style={{ left: `${leftPct}%`, transform: leftTranslate }}
|
style={{ left: `${leftPct}%`, transform: leftTranslate }}
|
||||||
|
|
@ -141,7 +141,7 @@ function SliderLabels({
|
||||||
<EditableLabel
|
<EditableLabel
|
||||||
value={labels[1]}
|
value={labels[1]}
|
||||||
formatted={maxLabel}
|
formatted={maxLabel}
|
||||||
onCommit={(v) => onValueChange([labels[0], Math.max(v, labels[0])])}
|
onCommit={(v) => onValueChange([Math.min(labels[0], v), v])}
|
||||||
prefix={feature.prefix}
|
prefix={feature.prefix}
|
||||||
suffix={feature.suffix}
|
suffix={feature.suffix}
|
||||||
style={{ left: `${rightPct}%`, transform: rightTranslate }}
|
style={{ left: `${rightPct}%`, transform: rightTranslate }}
|
||||||
|
|
@ -184,6 +184,7 @@ interface FiltersProps {
|
||||||
onTravelTimeRemoveEntry: (index: number) => void;
|
onTravelTimeRemoveEntry: (index: number) => void;
|
||||||
onTravelTimeSetDestination: (index: number, slug: string, label: string) => void;
|
onTravelTimeSetDestination: (index: number, slug: string, label: string) => void;
|
||||||
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
|
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
|
||||||
|
onTravelTimeDragEnd: (index: number) => void;
|
||||||
onTravelTimeToggleBest: (index: number) => void;
|
onTravelTimeToggleBest: (index: number) => void;
|
||||||
aiFilterLoading: boolean;
|
aiFilterLoading: boolean;
|
||||||
aiFilterError: string | null;
|
aiFilterError: string | null;
|
||||||
|
|
@ -220,6 +221,7 @@ export default memo(function Filters({
|
||||||
onTravelTimeRemoveEntry,
|
onTravelTimeRemoveEntry,
|
||||||
onTravelTimeSetDestination,
|
onTravelTimeSetDestination,
|
||||||
onTravelTimeRangeChange,
|
onTravelTimeRangeChange,
|
||||||
|
onTravelTimeDragEnd,
|
||||||
onTravelTimeToggleBest,
|
onTravelTimeToggleBest,
|
||||||
aiFilterLoading,
|
aiFilterLoading,
|
||||||
aiFilterError,
|
aiFilterError,
|
||||||
|
|
@ -415,7 +417,7 @@ export default memo(function Filters({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={scrollRef} className="md:flex-1 md:overflow-y-auto">
|
<div ref={scrollRef} className="md:flex-1 md:overflow-y-auto overflow-x-hidden">
|
||||||
<AiFilterInput
|
<AiFilterInput
|
||||||
loading={aiFilterLoading}
|
loading={aiFilterLoading}
|
||||||
error={aiFilterError}
|
error={aiFilterError}
|
||||||
|
|
@ -470,9 +472,14 @@ export default memo(function Filters({
|
||||||
timeRange={entry.timeRange}
|
timeRange={entry.timeRange}
|
||||||
useBest={entry.useBest}
|
useBest={entry.useBest}
|
||||||
isPinned={pinnedFeature === travelFieldKey(entry)}
|
isPinned={pinnedFeature === travelFieldKey(entry)}
|
||||||
|
isActive={activeFeature === travelFieldKey(entry)}
|
||||||
|
dragValue={activeFeature === travelFieldKey(entry) ? dragValue : null}
|
||||||
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
|
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
|
||||||
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
|
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
|
||||||
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
|
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
|
||||||
|
onDragStart={() => onDragStart(travelFieldKey(entry))}
|
||||||
|
onDragChange={onDragChange}
|
||||||
|
onDragEnd={() => onTravelTimeDragEnd(index)}
|
||||||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -592,7 +599,7 @@ export default memo(function Filters({
|
||||||
min={scale ? 0 : feature.min!}
|
min={scale ? 0 : feature.min!}
|
||||||
max={scale ? 100 : feature.max!}
|
max={scale ? 100 : feature.max!}
|
||||||
value={sliderValue}
|
value={sliderValue}
|
||||||
displayValues={scale ? displayValue : undefined}
|
displayValues={displayValue}
|
||||||
isAtMin={isAtMin}
|
isAtMin={isAtMin}
|
||||||
isAtMax={isAtMax}
|
isAtMax={isAtMax}
|
||||||
raw={feature.raw}
|
raw={feature.raw}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
|
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
|
||||||
import { Map as MapGL, useControl } from 'react-map-gl/maplibre';
|
import { Map as MapGL, useControl, ScaleControl } from 'react-map-gl/maplibre';
|
||||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -212,6 +212,9 @@ export default memo(function Map({
|
||||||
maxBounds={MAP_BOUNDS}
|
maxBounds={MAP_BOUNDS}
|
||||||
>
|
>
|
||||||
<DeckOverlay layers={layers} getTooltip={null} />
|
<DeckOverlay layers={layers} getTooltip={null} />
|
||||||
|
{!screenshotMode && (
|
||||||
|
<ScaleControl position="bottom-left" maxWidth={100} unit="metric" />
|
||||||
|
)}
|
||||||
</MapGL>
|
</MapGL>
|
||||||
{screenshotMode ? (
|
{screenshotMode ? (
|
||||||
ogMode ? (
|
ogMode ? (
|
||||||
|
|
|
||||||
|
|
@ -105,8 +105,8 @@ export default function MapPage({
|
||||||
const [selectedPOICategories, setSelectedPOICategories] =
|
const [selectedPOICategories, setSelectedPOICategories] =
|
||||||
useState<Set<string>>(initialPOICategories);
|
useState<Set<string>>(initialPOICategories);
|
||||||
|
|
||||||
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 600, 'left');
|
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left');
|
||||||
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 500, 'right');
|
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right');
|
||||||
|
|
||||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||||
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
||||||
|
|
@ -141,6 +141,7 @@ export default function MapPage({
|
||||||
handleDragStart,
|
handleDragStart,
|
||||||
handleDragChange,
|
handleDragChange,
|
||||||
handleDragEnd,
|
handleDragEnd,
|
||||||
|
handleDragEndNoCommit,
|
||||||
handleTogglePin,
|
handleTogglePin,
|
||||||
handleSetPin,
|
handleSetPin,
|
||||||
handleCancelPin,
|
handleCancelPin,
|
||||||
|
|
@ -204,12 +205,8 @@ export default function MapPage({
|
||||||
const handleTravelTimeSetDestination = useCallback(
|
const handleTravelTimeSetDestination = useCallback(
|
||||||
(index: number, slug: string, label: string) => {
|
(index: number, slug: string, label: string) => {
|
||||||
travelTime.handleSetDestination(index, slug, label);
|
travelTime.handleSetDestination(index, slug, label);
|
||||||
const entry = travelTime.entries[index];
|
|
||||||
if (entry) {
|
|
||||||
handleSetPin(`tt_${entry.mode}_${slug}`);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[travelTime.handleSetDestination, travelTime.entries, handleSetPin]
|
[travelTime.handleSetDestination]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTravelTimeRemoveEntry = useCallback(
|
const handleTravelTimeRemoveEntry = useCallback(
|
||||||
|
|
@ -223,6 +220,14 @@ export default function MapPage({
|
||||||
[travelTime.handleRemoveEntry, travelTime.entries, pinnedFeature, handleCancelPin]
|
[travelTime.handleRemoveEntry, travelTime.entries, pinnedFeature, handleCancelPin]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleTravelTimeDragEnd = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const dv = handleDragEndNoCommit();
|
||||||
|
if (dv) travelTime.handleTimeRangeChange(index, dv);
|
||||||
|
},
|
||||||
|
[handleDragEndNoCommit, travelTime.handleTimeRangeChange]
|
||||||
|
);
|
||||||
|
|
||||||
const license = useLicense();
|
const license = useLicense();
|
||||||
|
|
||||||
const mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null);
|
const mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null);
|
||||||
|
|
@ -571,6 +576,7 @@ export default function MapPage({
|
||||||
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
|
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
|
||||||
onTravelTimeSetDestination={handleTravelTimeSetDestination}
|
onTravelTimeSetDestination={handleTravelTimeSetDestination}
|
||||||
onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
|
onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
|
||||||
|
onTravelTimeDragEnd={handleTravelTimeDragEnd}
|
||||||
onTravelTimeToggleBest={travelTime.handleToggleBest}
|
onTravelTimeToggleBest={travelTime.handleToggleBest}
|
||||||
aiFilterLoading={aiFilters.loading}
|
aiFilterLoading={aiFilters.loading}
|
||||||
aiFilterError={aiFilters.error}
|
aiFilterError={aiFilters.error}
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,14 @@ interface TravelTimeCardProps {
|
||||||
timeRange: [number, number] | null;
|
timeRange: [number, number] | null;
|
||||||
useBest: boolean;
|
useBest: boolean;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
dragValue: [number, number] | null;
|
||||||
onTogglePin: () => void;
|
onTogglePin: () => void;
|
||||||
onSetDestination: (slug: string, label: string) => void;
|
onSetDestination: (slug: string, label: string) => void;
|
||||||
onTimeRangeChange: (range: [number, number]) => void;
|
onTimeRangeChange: (range: [number, number]) => void;
|
||||||
|
onDragStart: () => void;
|
||||||
|
onDragChange: (value: [number, number]) => void;
|
||||||
|
onDragEnd: () => void;
|
||||||
onToggleBest: () => void;
|
onToggleBest: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -33,9 +38,14 @@ export function TravelTimeCard({
|
||||||
timeRange,
|
timeRange,
|
||||||
useBest,
|
useBest,
|
||||||
isPinned,
|
isPinned,
|
||||||
|
isActive,
|
||||||
|
dragValue,
|
||||||
onTogglePin,
|
onTogglePin,
|
||||||
onSetDestination,
|
onSetDestination,
|
||||||
onTimeRangeChange,
|
onTimeRangeChange,
|
||||||
|
onDragStart,
|
||||||
|
onDragChange,
|
||||||
|
onDragEnd,
|
||||||
onToggleBest,
|
onToggleBest,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: TravelTimeCardProps) {
|
}: TravelTimeCardProps) {
|
||||||
|
|
@ -52,13 +62,13 @@ export function TravelTimeCard({
|
||||||
|
|
||||||
const sliderMin = 0;
|
const sliderMin = 0;
|
||||||
const sliderMax = 120;
|
const sliderMax = 120;
|
||||||
const displayRange = timeRange ?? [sliderMin, sliderMax];
|
const displayRange = isActive && dragValue ? dragValue : (timeRange ?? [sliderMin, sliderMax]);
|
||||||
|
|
||||||
const ModeIcon = MODE_ICONS[mode];
|
const ModeIcon = MODE_ICONS[mode];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`space-y-2 px-2 py-2 rounded ${isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
className={`space-y-2 px-2 py-2 rounded ${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' : ''}`}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -130,7 +140,9 @@ export function TravelTimeCard({
|
||||||
max={sliderMax}
|
max={sliderMax}
|
||||||
step={1}
|
step={1}
|
||||||
value={[displayRange[0], displayRange[1]]}
|
value={[displayRange[0], displayRange[1]]}
|
||||||
onValueChange={([min, max]) => onTimeRangeChange([min, max])}
|
onValueChange={([min, max]) => onDragChange([min, max])}
|
||||||
|
onPointerDown={() => onDragStart()}
|
||||||
|
onPointerUp={() => onDragEnd()}
|
||||||
/>
|
/>
|
||||||
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||||
<span className="absolute left-0">{formatFilterValue(displayRange[0])} min</span>
|
<span className="absolute left-0">{formatFilterValue(displayRange[0])} min</span>
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export function FeatureActions({
|
||||||
<div className="flex items-center gap-0.5 shrink-0">
|
<div className="flex items-center gap-0.5 shrink-0">
|
||||||
{feature.detail && onShowInfo && (
|
{feature.detail && onShowInfo && (
|
||||||
<IconButton onClick={() => onShowInfo(feature)} title="Feature info" size="md">
|
<IconButton onClick={() => onShowInfo(feature)} title="Feature info" size="md">
|
||||||
<InfoIcon className="w-7 h-7 md:w-3.5 md:h-3.5" />
|
<InfoIcon className="w-5 h-5 md:w-3.5 md:h-3.5" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|
@ -32,7 +32,7 @@ export function FeatureActions({
|
||||||
active={isPinned}
|
active={isPinned}
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
<EyeIcon filled={isPinned} className="w-7 h-7 md:w-3.5 md:h-3.5" />
|
<EyeIcon filled={isPinned} className="w-5 h-5 md:w-3.5 md:h-3.5" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{onAdd && (
|
{onAdd && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -40,7 +40,7 @@ export function FeatureActions({
|
||||||
title="Add filter"
|
title="Add filter"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<PlusIcon className="w-7 h-7 md:w-5 md:h-5" strokeWidth={2.5} />
|
<PlusIcon className="w-5 h-5 md:w-5 md:h-5" strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onRemove && (
|
{onRemove && (
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ interface FeatureLabelProps {
|
||||||
onShowInfo?: (feature: FeatureMeta) => void;
|
onShowInfo?: (feature: FeatureMeta) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
size?: 'xs' | 'sm';
|
size?: 'xs' | 'sm';
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeatureLabel({
|
export function FeatureLabel({
|
||||||
|
|
@ -21,6 +22,7 @@ export function FeatureLabel({
|
||||||
onShowInfo,
|
onShowInfo,
|
||||||
className = '',
|
className = '',
|
||||||
size = 'xs',
|
size = 'xs',
|
||||||
|
description,
|
||||||
}: FeatureLabelProps) {
|
}: FeatureLabelProps) {
|
||||||
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
|
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
|
||||||
const iconClass = 'w-3.5 h-3.5 text-teal-600 dark:text-teal-400 shrink-0';
|
const iconClass = 'w-3.5 h-3.5 text-teal-600 dark:text-teal-400 shrink-0';
|
||||||
|
|
@ -31,12 +33,8 @@ export function FeatureLabel({
|
||||||
? feature.modes.map((m) => MODE_LABELS[m] || m).join(' · ')
|
? feature.modes.map((m) => MODE_LABELS[m] || m).join(' · ')
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
const nameContent = (
|
||||||
<div
|
<>
|
||||||
className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} gap-1 min-w-0 ${className}`}
|
|
||||||
>
|
|
||||||
{featureIcon}
|
|
||||||
{GroupIcon && <GroupIcon className={iconClass} />}
|
|
||||||
<span
|
<span
|
||||||
className={`${textClass} ${size === 'sm' ? 'font-medium text-navy-950 dark:text-warm-100' : 'text-warm-700 dark:text-warm-300 truncate'}`}
|
className={`${textClass} ${size === 'sm' ? 'font-medium text-navy-950 dark:text-warm-100' : 'text-warm-700 dark:text-warm-300 truncate'}`}
|
||||||
>
|
>
|
||||||
|
|
@ -56,6 +54,23 @@ export function FeatureLabel({
|
||||||
<InfoIcon className="w-3.5 h-3.5" />
|
<InfoIcon className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} gap-1 min-w-0 ${className}`}
|
||||||
|
>
|
||||||
|
{featureIcon}
|
||||||
|
{GroupIcon && <GroupIcon className={iconClass} />}
|
||||||
|
{description ? (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-1">{nameContent}</div>
|
||||||
|
<span className="text-xs text-warm-400 dark:text-warm-500 block">{description}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
nameContent
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import {
|
||||||
POI_CLUSTER_MAX_ZOOM,
|
POI_CLUSTER_MAX_ZOOM,
|
||||||
} from '../lib/consts';
|
} from '../lib/consts';
|
||||||
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
|
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
|
||||||
import { type TravelTimeEntry, travelFieldKey } from './useTravelTime';
|
import type { TravelTimeEntry } from './useTravelTime';
|
||||||
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
|
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
|
||||||
|
|
||||||
interface UseDeckLayersProps {
|
interface UseDeckLayersProps {
|
||||||
|
|
@ -120,9 +120,6 @@ export function useDeckLayers({
|
||||||
const hoveredPostcodeRef = useRef(hoveredPostcode);
|
const hoveredPostcodeRef = useRef(hoveredPostcode);
|
||||||
hoveredPostcodeRef.current = hoveredPostcode;
|
hoveredPostcodeRef.current = hoveredPostcode;
|
||||||
|
|
||||||
const travelTimeEntriesRef = useRef(travelTimeEntries);
|
|
||||||
travelTimeEntriesRef.current = travelTimeEntries;
|
|
||||||
|
|
||||||
const colorFeatureMeta = useMemo(
|
const colorFeatureMeta = useMemo(
|
||||||
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
|
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
|
||||||
[viewFeature, features]
|
[viewFeature, features]
|
||||||
|
|
@ -302,28 +299,6 @@ export function useDeckLayers({
|
||||||
getHexagon: (d) => d.h3,
|
getHexagon: (d) => d.h3,
|
||||||
getFillColor: (d) => {
|
getFillColor: (d) => {
|
||||||
const dark = isDarkRef.current;
|
const dark = isDarkRef.current;
|
||||||
const entries = travelTimeEntriesRef.current;
|
|
||||||
|
|
||||||
// Dim-filter: all travel entries with timeRange dim hexagons outside range
|
|
||||||
for (let i = 0; i < entries.length; i++) {
|
|
||||||
const entry = entries[i];
|
|
||||||
if (!entry.timeRange || !entry.slug) continue;
|
|
||||||
const fk = travelFieldKey(entry);
|
|
||||||
const modeVal = d[`avg_${fk}`];
|
|
||||||
if (
|
|
||||||
modeVal == null ||
|
|
||||||
(modeVal as number) < entry.timeRange[0] ||
|
|
||||||
(modeVal as number) > entry.timeRange[1]
|
|
||||||
) {
|
|
||||||
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [
|
|
||||||
number,
|
|
||||||
number,
|
|
||||||
number,
|
|
||||||
number,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const vf = viewFeatureRef.current;
|
const vf = viewFeatureRef.current;
|
||||||
const clr = colorRangeRef.current;
|
const clr = colorRangeRef.current;
|
||||||
const fr = filterRangeRef.current;
|
const fr = filterRangeRef.current;
|
||||||
|
|
@ -425,28 +400,6 @@ export function useDeckLayers({
|
||||||
getFillColor: (f) => {
|
getFillColor: (f) => {
|
||||||
const d = f.properties;
|
const d = f.properties;
|
||||||
const dark = isDarkRef.current;
|
const dark = isDarkRef.current;
|
||||||
const entries = travelTimeEntriesRef.current;
|
|
||||||
|
|
||||||
// Dim-filter: all travel entries with timeRange dim postcodes outside range
|
|
||||||
for (let i = 0; i < entries.length; i++) {
|
|
||||||
const entry = entries[i];
|
|
||||||
if (!entry.timeRange || !entry.slug) continue;
|
|
||||||
const fk = travelFieldKey(entry);
|
|
||||||
const modeVal = d[`avg_${fk}`];
|
|
||||||
if (
|
|
||||||
modeVal == null ||
|
|
||||||
(modeVal as number) < entry.timeRange[0] ||
|
|
||||||
(modeVal as number) > entry.timeRange[1]
|
|
||||||
) {
|
|
||||||
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [
|
|
||||||
number,
|
|
||||||
number,
|
|
||||||
number,
|
|
||||||
number,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const vf = viewFeatureRef.current;
|
const vf = viewFeatureRef.current;
|
||||||
const clr = colorRangeRef.current;
|
const clr = colorRangeRef.current;
|
||||||
const fr = filterRangeRef.current;
|
const fr = filterRangeRef.current;
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,20 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||||
dragValueRef.current = null;
|
dragValueRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/** End drag without committing to filters — caller handles the commit (e.g. travel time). */
|
||||||
|
const handleDragEndNoCommit = useCallback((): [number, number] | null => {
|
||||||
|
if (pendingDragRef.current) {
|
||||||
|
pendingDragRef.current = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const dv = dragValueRef.current;
|
||||||
|
setActiveFeature(null);
|
||||||
|
setDragValue(null);
|
||||||
|
dragActiveRef.current = null;
|
||||||
|
dragValueRef.current = null;
|
||||||
|
return dv;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSetFilters = useCallback((newFilters: FeatureFilters) => {
|
const handleSetFilters = useCallback((newFilters: FeatureFilters) => {
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
setActiveFeature(null);
|
setActiveFeature(null);
|
||||||
|
|
@ -159,6 +173,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||||
handleDragStart,
|
handleDragStart,
|
||||||
handleDragChange,
|
handleDragChange,
|
||||||
handleDragEnd,
|
handleDragEnd,
|
||||||
|
handleDragEndNoCommit,
|
||||||
handleTogglePin,
|
handleTogglePin,
|
||||||
handleSetPin,
|
handleSetPin,
|
||||||
handleCancelPin,
|
handleCancelPin,
|
||||||
|
|
|
||||||
|
|
@ -81,25 +81,34 @@ export function useMapData({
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build the travel param string from entries with destinations.
|
// Build the travel param string from entries with destinations.
|
||||||
// timeRange is NOT included — range filtering is handled purely client-side
|
// Format: mode:slug[:best][:min:max] — server filters rows outside [min,max].
|
||||||
// (dimming in useDeckLayers) so slider changes never trigger server refetches.
|
// When excludeFieldKey is set, that entry's time range is omitted (for drag preview).
|
||||||
const travelParam = useMemo((): string => {
|
const buildTravelParam = useCallback(
|
||||||
|
(excludeFieldKey?: string): string => {
|
||||||
const segments: string[] = [];
|
const segments: string[] = [];
|
||||||
for (const entry of travelTimeEntries) {
|
for (const entry of travelTimeEntries) {
|
||||||
if (!entry.slug) continue;
|
if (!entry.slug) continue;
|
||||||
let seg = `${entry.mode}:${entry.slug}`;
|
let seg = `${entry.mode}:${entry.slug}`;
|
||||||
if (entry.useBest) seg += ':best';
|
if (entry.useBest) seg += ':best';
|
||||||
|
const isExcluded = excludeFieldKey === `tt_${entry.mode}_${entry.slug}`;
|
||||||
|
if (entry.timeRange && !isExcluded) seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
|
||||||
segments.push(seg);
|
segments.push(seg);
|
||||||
}
|
}
|
||||||
return segments.join('|');
|
return segments.join('|');
|
||||||
}, [travelTimeEntries]);
|
},
|
||||||
|
[travelTimeEntries]
|
||||||
|
);
|
||||||
|
|
||||||
|
const travelParam = useMemo(() => buildTravelParam(), [buildTravelParam]);
|
||||||
|
|
||||||
// Keep activeFeatureRef in sync
|
// Keep activeFeatureRef in sync
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeFeatureRef.current = activeFeature;
|
activeFeatureRef.current = activeFeature;
|
||||||
}, [activeFeature]);
|
}, [activeFeature]);
|
||||||
|
|
||||||
// Drag prefetch: when activeFeature starts, fetch data excluding that filter
|
// Drag prefetch: when activeFeature starts, fetch data excluding that filter.
|
||||||
|
// For regular filters: excludes the filter from the filter string.
|
||||||
|
// For travel time: excludes the time range from that entry's travel param segment.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeFeature || !bounds) return;
|
if (!activeFeature || !bounds) return;
|
||||||
|
|
||||||
|
|
@ -108,11 +117,14 @@ export function useMapData({
|
||||||
|
|
||||||
const filtersStr = buildFilterString(filters, features, activeFeature);
|
const filtersStr = buildFilterString(filters, features, activeFeature);
|
||||||
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
||||||
|
const isTravelTimeDrag = activeFeature.startsWith('tt_');
|
||||||
|
const dragTravelParam = isTravelTimeDrag ? buildTravelParam(activeFeature) : travelParam;
|
||||||
|
|
||||||
if (usePostcodeView) {
|
if (usePostcodeView) {
|
||||||
const params = new URLSearchParams({ bounds: boundsStr });
|
const params = new URLSearchParams({ bounds: boundsStr });
|
||||||
if (filtersStr) params.set('filters', filtersStr);
|
if (filtersStr) params.set('filters', filtersStr);
|
||||||
params.set('fields', activeFeature);
|
params.set('fields', activeFeature);
|
||||||
|
if (dragTravelParam) params.set('travel', dragTravelParam);
|
||||||
|
|
||||||
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
|
|
@ -129,7 +141,7 @@ export function useMapData({
|
||||||
});
|
});
|
||||||
if (filtersStr) params.set('filters', filtersStr);
|
if (filtersStr) params.set('filters', filtersStr);
|
||||||
params.set('fields', activeFeature);
|
params.set('fields', activeFeature);
|
||||||
if (travelParam) params.set('travel', travelParam);
|
if (dragTravelParam) params.set('travel', dragTravelParam);
|
||||||
|
|
||||||
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
|
|
@ -147,7 +159,7 @@ export function useMapData({
|
||||||
dragAbortRef.current = null;
|
dragAbortRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [activeFeature, bounds, resolution, filters, features, usePostcodeView, travelParam]);
|
}, [activeFeature, bounds, resolution, filters, features, usePostcodeView, travelParam, buildTravelParam]);
|
||||||
|
|
||||||
// Fetch hexagons or postcodes when bounds/filters change
|
// Fetch hexagons or postcodes when bounds/filters change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,11 @@ export function usePaneResize(
|
||||||
const handlePointerMove = useCallback(
|
const handlePointerMove = useCallback(
|
||||||
(e: React.PointerEvent) => {
|
(e: React.PointerEvent) => {
|
||||||
if (!draggingRef.current) return;
|
if (!draggingRef.current) return;
|
||||||
|
const resolvedMax = maxWidth <= 1 ? window.innerWidth * maxWidth : maxWidth;
|
||||||
const newWidth =
|
const newWidth =
|
||||||
side === 'left'
|
side === 'left'
|
||||||
? Math.min(maxWidth, Math.max(minWidth, e.clientX))
|
? Math.min(resolvedMax, Math.max(minWidth, e.clientX))
|
||||||
: Math.min(maxWidth, Math.max(minWidth, window.innerWidth - e.clientX));
|
: Math.min(resolvedMax, Math.max(minWidth, window.innerWidth - e.clientX));
|
||||||
setWidth(newWidth);
|
setWidth(newWidth);
|
||||||
},
|
},
|
||||||
[side, minWidth, maxWidth]
|
[side, minWidth, maxWidth]
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,13 @@ h3 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* MapLibre scale control — dark mode */
|
||||||
|
.dark .maplibregl-ctrl-scale {
|
||||||
|
border-color: #d6d3d1;
|
||||||
|
color: #d6d3d1;
|
||||||
|
background-color: rgba(28, 25, 23, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
/* Hide scrollbar for pill groups on mobile */
|
/* Hide scrollbar for pill groups on mobile */
|
||||||
.scrollbar-hide {
|
.scrollbar-hide {
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import {
|
||||||
BUFFER_MULTIPLIER,
|
BUFFER_MULTIPLIER,
|
||||||
ENUM_PALETTE,
|
ENUM_PALETTE,
|
||||||
} from './consts';
|
} from './consts';
|
||||||
|
|
||||||
const ROAD_OPACITY = 0.4;
|
const ROAD_OPACITY = 0.4;
|
||||||
|
|
||||||
export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
|
export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue