From 6757eb2086afb115204af7a4246223ed772de21c Mon Sep 17 00:00:00 2001 From: taDuc Date: Sat, 9 May 2026 23:02:48 +0700 Subject: [PATCH] time query by range | filter by type --- src/app/editor/[id]/page.tsx | 13 +++++- src/app/page.tsx | 23 +++++++++- src/components/ui/button/Button.tsx | 16 +++---- src/uhm/api/geometries.ts | 4 ++ src/uhm/components/BackgroundLayersPanel.tsx | 44 +++++++++++++++++++ src/uhm/components/Map.tsx | 31 +++++++++++++- src/uhm/components/TimelineBar.tsx | 45 ++++++++++++++++++++ src/uhm/lib/geoTypeMap.ts | 9 +++- src/uhm/types/api.ts | 2 + 9 files changed, 172 insertions(+), 15 deletions(-) diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 39f7381..e9b6cbe 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -23,7 +23,7 @@ import { Geometry, useEditorState, } from "@/uhm/lib/useEditorState"; -import { geoTypeCodeToTypeKey } from "@/uhm/lib/geoTypeMap"; +import { GEO_TYPE_KEYS, geoTypeCodeToTypeKey } from "@/uhm/lib/geoTypeMap"; import { BackgroundLayerId, BackgroundLayerVisibility, @@ -167,6 +167,12 @@ export default function Page() { const wikiSearchRequestRef = useRef(0); const geoSearchRequestRef = useRef(0); + const [geometryVisibility, setGeometryVisibility] = useState>(() => { + const init: Record = {}; + for (const key of GEO_TYPE_KEYS) init[key] = true; + return init; + }); + const snapshotEntitiesRef = useRef(snapshotEntities); const snapshotWikisRef = useRef(snapshotWikis); const snapshotEntityWikiLinksRef = useRef(snapshotEntityWikiLinks); @@ -1211,6 +1217,7 @@ export default function Page() { onDeleteFeature={editor.deleteFeature} onUpdateFeature={editor.updateFeature} backgroundVisibility={backgroundVisibility} + geometryVisibility={geometryVisibility} respectBindingFilter={geometryBindingFilterEnabled} /> ) : ( @@ -1244,6 +1251,10 @@ export default function Page() { onToggleLayer={handleToggleBackgroundLayer} onShowAll={handleShowAllBackgroundLayers} onHideAll={handleHideAllBackgroundLayers} + geometryVisibility={geometryVisibility} + onToggleGeometryType={(typeKey) => { + setGeometryVisibility((prev) => ({ ...prev, [typeKey]: prev[typeKey] === false })); + }} width={rightPanelWidth} topContent={
diff --git a/src/app/page.tsx b/src/app/page.tsx index bcca9c4..6388ed2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -19,6 +19,7 @@ import { } from "@/uhm/lib/editor/background/backgroundVisibilityStorage"; import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/uhm/lib/geo/constants"; import { clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/uhm/lib/timeline"; +import { GEO_TYPE_KEYS } from "@/uhm/lib/geoTypeMap"; import type { Feature, FeatureCollection } from "@/uhm/types/geo"; const CURRENT_YEAR = new Date().getUTCFullYear(); @@ -28,6 +29,7 @@ export default function Page() { const [selectedFeatureId, setSelectedFeatureId] = useState(null); const [timelineYear, setTimelineYear] = useState(() => clampYearToFixedRange(CURRENT_YEAR)); const [timelineDraftYear, setTimelineDraftYear] = useState(() => clampYearToFixedRange(CURRENT_YEAR)); + const [timeRange, setTimeRange] = useState(0); const [isTimelineLoading, setIsTimelineLoading] = useState(false); const [timelineStatus, setTimelineStatus] = useState(null); const [backgroundVisibility, setBackgroundVisibility] = useState( @@ -36,6 +38,11 @@ export default function Page() { const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false); const timelineFetchRequestRef = useRef(0); const [lastLoadedAt, setLastLoadedAt] = useState(null); + const [geometryVisibility, setGeometryVisibility] = useState>(() => { + const init: Record = {}; + for (const key of GEO_TYPE_KEYS) init[key] = true; + return init; + }); const selectedFeature: Feature | null = useMemo(() => { if (selectedFeatureId === null) return null; @@ -70,7 +77,7 @@ export default function Page() { setIsTimelineLoading(true); setTimelineStatus(null); try { - const next = await fetchGeometriesByBBox({ ...WORLD_BBOX, time: timelineYear }); + const next = await fetchGeometriesByBBox({ ...WORLD_BBOX, time: timelineYear, timeRange }); if (disposed || requestId !== timelineFetchRequestRef.current) return; setData(next); setLastLoadedAt(new Date().toISOString()); @@ -94,7 +101,7 @@ export default function Page() { return () => { disposed = true; }; - }, [timelineYear]); + }, [timelineYear, timeRange]); const updateBackgroundVisibility = (updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility) => { setBackgroundVisibility((prev) => { @@ -120,6 +127,11 @@ export default function Page() { setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear))); }; + const handleTimeRangeChange = (nextRange: number) => { + const safe = Number.isFinite(nextRange) ? Math.trunc(nextRange) : 0; + setTimeRange(Math.max(0, Math.min(30, safe))); + }; + return (
@@ -130,6 +142,7 @@ export default function Page() { selectedFeatureId={selectedFeatureId} onSelectFeatureId={setSelectedFeatureId} backgroundVisibility={backgroundVisibility} + geometryVisibility={geometryVisibility} allowGeometryEditing={false} respectBindingFilter={false} /> @@ -140,6 +153,8 @@ export default function Page() { { + setGeometryVisibility((prev) => ({ ...prev, [typeKey]: prev[typeKey] === false })); + }} topContent={
& { size?: "sm" | "md"; // Button size variant?: "primary" | "outline"; // Button variant startIcon?: ReactNode; // Icon before the text endIcon?: ReactNode; // Icon after the text - onClick?: () => void; // Click handler - disabled?: boolean; // Disabled state - className?: string; // Disabled state - type?: "button" | "submit" | "reset"; -} +}; const Button: React.FC = ({ children, @@ -18,10 +13,10 @@ const Button: React.FC = ({ variant = "primary", startIcon, endIcon, - onClick, className = "", disabled = false, - type = "button", + type = "button", + ...rest }) => { // Size Classes const sizeClasses = { @@ -44,8 +39,9 @@ const Button: React.FC = ({ } ${variantClasses[variant]} ${ disabled ? "cursor-not-allowed opacity-50" : "" }`} - onClick={onClick} disabled={disabled} + type={type} + {...rest} > {startIcon && {startIcon}} {children} diff --git a/src/uhm/api/geometries.ts b/src/uhm/api/geometries.ts index 3215c1a..f27e04e 100644 --- a/src/uhm/api/geometries.ts +++ b/src/uhm/api/geometries.ts @@ -40,6 +40,10 @@ function buildBBoxQueryString(params: GeometriesBBoxQuery): string { query.set("time", String(params.time)); } + if (params.timeRange !== undefined) { + query.set("time_range", String(params.timeRange)); + } + if (params.entity_id) { query.set("entity_id", params.entity_id); } diff --git a/src/uhm/components/BackgroundLayersPanel.tsx b/src/uhm/components/BackgroundLayersPanel.tsx index 39b86a0..00e7215 100644 --- a/src/uhm/components/BackgroundLayersPanel.tsx +++ b/src/uhm/components/BackgroundLayersPanel.tsx @@ -6,12 +6,15 @@ import { BackgroundLayerId, BackgroundLayerVisibility, } from "@/uhm/lib/backgroundLayers"; +import { GEO_TYPE_KEYS } from "@/uhm/lib/geoTypeMap"; type Props = { visibility: BackgroundLayerVisibility; onToggleLayer: (id: BackgroundLayerId) => void; onShowAll: () => void; onHideAll: () => void; + geometryVisibility?: Record; + onToggleGeometryType?: (typeKey: string) => void; topContent?: ReactNode; width?: number; }; @@ -21,6 +24,8 @@ export default function BackgroundLayersPanel({ onToggleLayer, onShowAll, onHideAll, + geometryVisibility, + onToggleGeometryType, topContent, width = 240, }: Props) { @@ -69,6 +74,45 @@ export default function BackgroundLayersPanel({ ); })}
+ + {geometryVisibility && onToggleGeometryType ? ( + <> +
+
+ Geometries +
+
+ {GEO_TYPE_KEYS.map((typeKey) => { + const on = geometryVisibility[typeKey] !== false; + return ( + + ); + })} +
+ + ) : null} ); } diff --git a/src/uhm/components/Map.tsx b/src/uhm/components/Map.tsx index 8c7b28e..e976f56 100644 --- a/src/uhm/components/Map.tsx +++ b/src/uhm/components/Map.tsx @@ -42,6 +42,7 @@ type MapProps = { mode: EditorMode; draft: FeatureCollection; backgroundVisibility: BackgroundLayerVisibility; + geometryVisibility?: Record; selectedFeatureId: string | number | null; onSelectFeatureId: (id: string | number | null) => void; onCreateFeature?: (feature: FeatureCollection["features"][number]) => void; @@ -70,6 +71,7 @@ export default function Map({ mode, draft, backgroundVisibility, + geometryVisibility, selectedFeatureId, onSelectFeatureId, onCreateFeature, @@ -98,6 +100,8 @@ export default function Map({ const draftRef = useRef(draft); // Mirror của backgroundVisibility để sync visibility khi map đã load. const backgroundVisibilityRef = useRef(backgroundVisibility); + // Mirror of geometry visibility for type filtering. + const geometryVisibilityRef = useRef(geometryVisibility); // Mirror của selectedFeatureId để filter/select trên map (không phụ thuộc re-render). const selectedFeatureIdRef = useRef(selectedFeatureId); // Mirror của callback onSelectFeatureId. @@ -223,6 +227,14 @@ export default function Map({ applyBackgroundLayerVisibility(map, backgroundVisibility); }, [backgroundVisibility]); + useEffect(() => { + geometryVisibilityRef.current = geometryVisibility; + // When toggling geometry types, refresh sources immediately (without waiting for parent re-mount). + const map = mapRef.current; + if (!map) return; + applyDraftToMap(draftRef.current); + }, [geometryVisibility]); + useEffect(() => { onCreateRef.current = onCreateFeature; }, [onCreateFeature]); @@ -264,9 +276,10 @@ export default function Map({ } } - const visibleDraft = respectBindingFilterRef.current + const visibleDraftRaw = respectBindingFilterRef.current ? filterDraftByBinding(fc, selectedFeatureIdRef.current) : fc; + const visibleDraft = filterDraftByGeometryVisibility(visibleDraftRaw, geometryVisibilityRef.current); const { polygons, points } = splitDraftFeatures(visibleDraft); const pathArrowShapes = buildPathArrowFeatureCollection(visibleDraft); @@ -1397,6 +1410,22 @@ function filterDraftByBinding( }; } +function filterDraftByGeometryVisibility( + fc: FeatureCollection, + visibility: Record | null | undefined +): FeatureCollection { + if (!visibility) return fc; + + return { + ...fc, + features: fc.features.filter((feature) => { + const key = getFeatureSemanticType(feature); + if (!key) return true; + return visibility[key] !== false; + }), + }; +} + function normalizeBindingIds(rawBinding: unknown): string[] { if (!Array.isArray(rawBinding)) return []; const deduped: string[] = []; diff --git a/src/uhm/components/TimelineBar.tsx b/src/uhm/components/TimelineBar.tsx index 41880e9..6e7ea58 100644 --- a/src/uhm/components/TimelineBar.tsx +++ b/src/uhm/components/TimelineBar.tsx @@ -5,6 +5,8 @@ import { FIXED_TIMELINE_END_YEAR, FIXED_TIMELINE_START_YEAR, clampYearValue } fr type Props = { year: number; onYearChange: (year: number) => void; + timeRange?: number; + onTimeRangeChange?: (range: number) => void; isLoading: boolean; disabled: boolean; statusText?: string | null; @@ -15,6 +17,8 @@ type Props = { export default function TimelineBar({ year, onYearChange, + timeRange, + onTimeRangeChange, isLoading, disabled, statusText, @@ -34,6 +38,12 @@ export default function TimelineBar({ onYearChange(clampYearValue(Math.trunc(nextYear), lower, upper)); }; + const handleTimeRangeChange = (nextValue: number) => { + if (!onTimeRangeChange) return; + const safe = Number.isFinite(nextValue) ? Math.trunc(nextValue) : 0; + onTimeRangeChange(Math.max(0, Math.min(30, safe))); + }; + return (
+ {typeof timeRange === "number" && onTimeRangeChange ? ( + + ) : null}
); diff --git a/src/uhm/lib/geoTypeMap.ts b/src/uhm/lib/geoTypeMap.ts index 3f5fa7c..b9123ce 100644 --- a/src/uhm/lib/geoTypeMap.ts +++ b/src/uhm/lib/geoTypeMap.ts @@ -7,6 +7,14 @@ export type GeoTypeMapRow = { const MAP_ROWS: GeoTypeMapRow[] = rows as GeoTypeMapRow[]; +export const GEO_TYPE_KEYS: string[] = Array.from( + new Set( + MAP_ROWS + .map((row) => (typeof row?.type_key === "string" ? row.type_key.trim().toLowerCase() : "")) + .filter((key) => key.length > 0) + ) +); + const CODE_BY_KEY = new Map(); const KEY_BY_CODE = new Map(); @@ -31,4 +39,3 @@ export function geoTypeCodeToTypeKey(code: number | null | undefined): string | if (!Number.isFinite(code)) return null; return KEY_BY_CODE.get(Math.trunc(code)) ?? null; } - diff --git a/src/uhm/types/api.ts b/src/uhm/types/api.ts index f13489e..7fea91a 100644 --- a/src/uhm/types/api.ts +++ b/src/uhm/types/api.ts @@ -14,5 +14,7 @@ export type GeometriesBBoxQuery = { maxLng: number; maxLat: number; time?: number; + // New API: time_range (0-30) to expand the timeline query window. + timeRange?: number; entity_id?: string; };