time query by range | filter by type

This commit is contained in:
taDuc
2026-05-09 23:02:48 +07:00
parent a9b8c4ab8b
commit 6757eb2086
9 changed files with 172 additions and 15 deletions
+12 -1
View File
@@ -23,7 +23,7 @@ import {
Geometry, Geometry,
useEditorState, useEditorState,
} from "@/uhm/lib/useEditorState"; } from "@/uhm/lib/useEditorState";
import { geoTypeCodeToTypeKey } from "@/uhm/lib/geoTypeMap"; import { GEO_TYPE_KEYS, geoTypeCodeToTypeKey } from "@/uhm/lib/geoTypeMap";
import { import {
BackgroundLayerId, BackgroundLayerId,
BackgroundLayerVisibility, BackgroundLayerVisibility,
@@ -167,6 +167,12 @@ export default function Page() {
const wikiSearchRequestRef = useRef(0); const wikiSearchRequestRef = useRef(0);
const geoSearchRequestRef = useRef(0); const geoSearchRequestRef = useRef(0);
const [geometryVisibility, setGeometryVisibility] = useState<Record<string, boolean>>(() => {
const init: Record<string, boolean> = {};
for (const key of GEO_TYPE_KEYS) init[key] = true;
return init;
});
const snapshotEntitiesRef = useRef(snapshotEntities); const snapshotEntitiesRef = useRef(snapshotEntities);
const snapshotWikisRef = useRef(snapshotWikis); const snapshotWikisRef = useRef(snapshotWikis);
const snapshotEntityWikiLinksRef = useRef(snapshotEntityWikiLinks); const snapshotEntityWikiLinksRef = useRef(snapshotEntityWikiLinks);
@@ -1211,6 +1217,7 @@ export default function Page() {
onDeleteFeature={editor.deleteFeature} onDeleteFeature={editor.deleteFeature}
onUpdateFeature={editor.updateFeature} onUpdateFeature={editor.updateFeature}
backgroundVisibility={backgroundVisibility} backgroundVisibility={backgroundVisibility}
geometryVisibility={geometryVisibility}
respectBindingFilter={geometryBindingFilterEnabled} respectBindingFilter={geometryBindingFilterEnabled}
/> />
) : ( ) : (
@@ -1244,6 +1251,10 @@ export default function Page() {
onToggleLayer={handleToggleBackgroundLayer} onToggleLayer={handleToggleBackgroundLayer}
onShowAll={handleShowAllBackgroundLayers} onShowAll={handleShowAllBackgroundLayers}
onHideAll={handleHideAllBackgroundLayers} onHideAll={handleHideAllBackgroundLayers}
geometryVisibility={geometryVisibility}
onToggleGeometryType={(typeKey) => {
setGeometryVisibility((prev) => ({ ...prev, [typeKey]: prev[typeKey] === false }));
}}
width={rightPanelWidth} width={rightPanelWidth}
topContent={ topContent={
<div style={{ display: "grid", gap: "12px" }}> <div style={{ display: "grid", gap: "12px" }}>
+21 -2
View File
@@ -19,6 +19,7 @@ import {
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage"; } from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/uhm/lib/geo/constants"; import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/uhm/lib/geo/constants";
import { clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/uhm/lib/timeline"; 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"; import type { Feature, FeatureCollection } from "@/uhm/types/geo";
const CURRENT_YEAR = new Date().getUTCFullYear(); const CURRENT_YEAR = new Date().getUTCFullYear();
@@ -28,6 +29,7 @@ export default function Page() {
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null); const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
const [timelineYear, setTimelineYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR)); const [timelineYear, setTimelineYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR));
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR)); const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR));
const [timeRange, setTimeRange] = useState<number>(0);
const [isTimelineLoading, setIsTimelineLoading] = useState(false); const [isTimelineLoading, setIsTimelineLoading] = useState(false);
const [timelineStatus, setTimelineStatus] = useState<string | null>(null); const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>( const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
@@ -36,6 +38,11 @@ export default function Page() {
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false); const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
const timelineFetchRequestRef = useRef(0); const timelineFetchRequestRef = useRef(0);
const [lastLoadedAt, setLastLoadedAt] = useState<string | null>(null); const [lastLoadedAt, setLastLoadedAt] = useState<string | null>(null);
const [geometryVisibility, setGeometryVisibility] = useState<Record<string, boolean>>(() => {
const init: Record<string, boolean> = {};
for (const key of GEO_TYPE_KEYS) init[key] = true;
return init;
});
const selectedFeature: Feature | null = useMemo(() => { const selectedFeature: Feature | null = useMemo(() => {
if (selectedFeatureId === null) return null; if (selectedFeatureId === null) return null;
@@ -70,7 +77,7 @@ export default function Page() {
setIsTimelineLoading(true); setIsTimelineLoading(true);
setTimelineStatus(null); setTimelineStatus(null);
try { try {
const next = await fetchGeometriesByBBox({ ...WORLD_BBOX, time: timelineYear }); const next = await fetchGeometriesByBBox({ ...WORLD_BBOX, time: timelineYear, timeRange });
if (disposed || requestId !== timelineFetchRequestRef.current) return; if (disposed || requestId !== timelineFetchRequestRef.current) return;
setData(next); setData(next);
setLastLoadedAt(new Date().toISOString()); setLastLoadedAt(new Date().toISOString());
@@ -94,7 +101,7 @@ export default function Page() {
return () => { return () => {
disposed = true; disposed = true;
}; };
}, [timelineYear]); }, [timelineYear, timeRange]);
const updateBackgroundVisibility = (updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility) => { const updateBackgroundVisibility = (updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility) => {
setBackgroundVisibility((prev) => { setBackgroundVisibility((prev) => {
@@ -120,6 +127,11 @@ export default function Page() {
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear))); 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 ( return (
<div style={{ display: "flex", minHeight: "100vh" }}> <div style={{ display: "flex", minHeight: "100vh" }}>
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}> <div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
@@ -130,6 +142,7 @@ export default function Page() {
selectedFeatureId={selectedFeatureId} selectedFeatureId={selectedFeatureId}
onSelectFeatureId={setSelectedFeatureId} onSelectFeatureId={setSelectedFeatureId}
backgroundVisibility={backgroundVisibility} backgroundVisibility={backgroundVisibility}
geometryVisibility={geometryVisibility}
allowGeometryEditing={false} allowGeometryEditing={false}
respectBindingFilter={false} respectBindingFilter={false}
/> />
@@ -140,6 +153,8 @@ export default function Page() {
<TimelineBar <TimelineBar
year={timelineDraftYear} year={timelineDraftYear}
onYearChange={handleTimelineYearChange} onYearChange={handleTimelineYearChange}
timeRange={timeRange}
onTimeRangeChange={handleTimeRangeChange}
isLoading={isTimelineLoading} isLoading={isTimelineLoading}
disabled={false} disabled={false}
statusText={timelineStatus} statusText={timelineStatus}
@@ -151,6 +166,10 @@ export default function Page() {
onToggleLayer={handleToggleBackgroundLayer} onToggleLayer={handleToggleBackgroundLayer}
onShowAll={handleShowAllBackgroundLayers} onShowAll={handleShowAllBackgroundLayers}
onHideAll={handleHideAllBackgroundLayers} onHideAll={handleHideAllBackgroundLayers}
geometryVisibility={geometryVisibility}
onToggleGeometryType={(typeKey) => {
setGeometryVisibility((prev) => ({ ...prev, [typeKey]: prev[typeKey] === false }));
}}
topContent={ topContent={
<div <div
style={{ style={{
+5 -9
View File
@@ -1,16 +1,11 @@
import React, { ReactNode } from "react"; import React, { ReactNode } from "react";
interface ButtonProps { type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
children: ReactNode; // Button text or content
size?: "sm" | "md"; // Button size size?: "sm" | "md"; // Button size
variant?: "primary" | "outline"; // Button variant variant?: "primary" | "outline"; // Button variant
startIcon?: ReactNode; // Icon before the text startIcon?: ReactNode; // Icon before the text
endIcon?: ReactNode; // Icon after 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<ButtonProps> = ({ const Button: React.FC<ButtonProps> = ({
children, children,
@@ -18,10 +13,10 @@ const Button: React.FC<ButtonProps> = ({
variant = "primary", variant = "primary",
startIcon, startIcon,
endIcon, endIcon,
onClick,
className = "", className = "",
disabled = false, disabled = false,
type = "button", type = "button",
...rest
}) => { }) => {
// Size Classes // Size Classes
const sizeClasses = { const sizeClasses = {
@@ -44,8 +39,9 @@ const Button: React.FC<ButtonProps> = ({
} ${variantClasses[variant]} ${ } ${variantClasses[variant]} ${
disabled ? "cursor-not-allowed opacity-50" : "" disabled ? "cursor-not-allowed opacity-50" : ""
}`} }`}
onClick={onClick}
disabled={disabled} disabled={disabled}
type={type}
{...rest}
> >
{startIcon && <span className="flex items-center">{startIcon}</span>} {startIcon && <span className="flex items-center">{startIcon}</span>}
{children} {children}
+4
View File
@@ -40,6 +40,10 @@ function buildBBoxQueryString(params: GeometriesBBoxQuery): string {
query.set("time", String(params.time)); query.set("time", String(params.time));
} }
if (params.timeRange !== undefined) {
query.set("time_range", String(params.timeRange));
}
if (params.entity_id) { if (params.entity_id) {
query.set("entity_id", params.entity_id); query.set("entity_id", params.entity_id);
} }
@@ -6,12 +6,15 @@ import {
BackgroundLayerId, BackgroundLayerId,
BackgroundLayerVisibility, BackgroundLayerVisibility,
} from "@/uhm/lib/backgroundLayers"; } from "@/uhm/lib/backgroundLayers";
import { GEO_TYPE_KEYS } from "@/uhm/lib/geoTypeMap";
type Props = { type Props = {
visibility: BackgroundLayerVisibility; visibility: BackgroundLayerVisibility;
onToggleLayer: (id: BackgroundLayerId) => void; onToggleLayer: (id: BackgroundLayerId) => void;
onShowAll: () => void; onShowAll: () => void;
onHideAll: () => void; onHideAll: () => void;
geometryVisibility?: Record<string, boolean>;
onToggleGeometryType?: (typeKey: string) => void;
topContent?: ReactNode; topContent?: ReactNode;
width?: number; width?: number;
}; };
@@ -21,6 +24,8 @@ export default function BackgroundLayersPanel({
onToggleLayer, onToggleLayer,
onShowAll, onShowAll,
onHideAll, onHideAll,
geometryVisibility,
onToggleGeometryType,
topContent, topContent,
width = 240, width = 240,
}: Props) { }: Props) {
@@ -69,6 +74,45 @@ export default function BackgroundLayersPanel({
); );
})} })}
</div> </div>
{geometryVisibility && onToggleGeometryType ? (
<>
<div style={{ height: 1, background: "#1f2937", margin: "12px 0" }} />
<div style={{ margin: 0, marginBottom: 10, fontWeight: 800, fontSize: 13, color: "#e5e7eb" }}>
Geometries
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px 12px" }}>
{GEO_TYPE_KEYS.map((typeKey) => {
const on = geometryVisibility[typeKey] !== false;
return (
<button
key={typeKey}
type="button"
onClick={() => onToggleGeometryType(typeKey)}
style={{
border: "none",
background: "transparent",
padding: 0,
margin: 0,
cursor: "pointer",
color: on ? "#22c55e" : "#e5e7eb",
textDecorationLine: on ? "none" : "line-through",
textDecorationThickness: on ? undefined : "2px",
textDecorationColor: on ? undefined : "rgba(148, 163, 184, 0.7)",
fontSize: 13,
fontWeight: 750,
whiteSpace: "nowrap",
textTransform: "capitalize",
}}
title={on ? "On" : "Off"}
>
{typeKey.replaceAll("_", " ")}
</button>
);
})}
</div>
</>
) : null}
</aside> </aside>
); );
} }
+30 -1
View File
@@ -42,6 +42,7 @@ type MapProps = {
mode: EditorMode; mode: EditorMode;
draft: FeatureCollection; draft: FeatureCollection;
backgroundVisibility: BackgroundLayerVisibility; backgroundVisibility: BackgroundLayerVisibility;
geometryVisibility?: Record<string, boolean>;
selectedFeatureId: string | number | null; selectedFeatureId: string | number | null;
onSelectFeatureId: (id: string | number | null) => void; onSelectFeatureId: (id: string | number | null) => void;
onCreateFeature?: (feature: FeatureCollection["features"][number]) => void; onCreateFeature?: (feature: FeatureCollection["features"][number]) => void;
@@ -70,6 +71,7 @@ export default function Map({
mode, mode,
draft, draft,
backgroundVisibility, backgroundVisibility,
geometryVisibility,
selectedFeatureId, selectedFeatureId,
onSelectFeatureId, onSelectFeatureId,
onCreateFeature, onCreateFeature,
@@ -98,6 +100,8 @@ export default function Map({
const draftRef = useRef<FeatureCollection>(draft); const draftRef = useRef<FeatureCollection>(draft);
// Mirror của backgroundVisibility để sync visibility khi map đã load. // Mirror của backgroundVisibility để sync visibility khi map đã load.
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility); const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
// Mirror of geometry visibility for type filtering.
const geometryVisibilityRef = useRef<MapProps["geometryVisibility"]>(geometryVisibility);
// Mirror của selectedFeatureId để filter/select trên map (không phụ thuộc re-render). // Mirror của selectedFeatureId để filter/select trên map (không phụ thuộc re-render).
const selectedFeatureIdRef = useRef<string | number | null>(selectedFeatureId); const selectedFeatureIdRef = useRef<string | number | null>(selectedFeatureId);
// Mirror của callback onSelectFeatureId. // Mirror của callback onSelectFeatureId.
@@ -223,6 +227,14 @@ export default function Map({
applyBackgroundLayerVisibility(map, backgroundVisibility); applyBackgroundLayerVisibility(map, backgroundVisibility);
}, [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(() => { useEffect(() => {
onCreateRef.current = onCreateFeature; onCreateRef.current = onCreateFeature;
}, [onCreateFeature]); }, [onCreateFeature]);
@@ -264,9 +276,10 @@ export default function Map({
} }
} }
const visibleDraft = respectBindingFilterRef.current const visibleDraftRaw = respectBindingFilterRef.current
? filterDraftByBinding(fc, selectedFeatureIdRef.current) ? filterDraftByBinding(fc, selectedFeatureIdRef.current)
: fc; : fc;
const visibleDraft = filterDraftByGeometryVisibility(visibleDraftRaw, geometryVisibilityRef.current);
const { polygons, points } = splitDraftFeatures(visibleDraft); const { polygons, points } = splitDraftFeatures(visibleDraft);
const pathArrowShapes = buildPathArrowFeatureCollection(visibleDraft); const pathArrowShapes = buildPathArrowFeatureCollection(visibleDraft);
@@ -1397,6 +1410,22 @@ function filterDraftByBinding(
}; };
} }
function filterDraftByGeometryVisibility(
fc: FeatureCollection,
visibility: Record<string, boolean> | 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[] { function normalizeBindingIds(rawBinding: unknown): string[] {
if (!Array.isArray(rawBinding)) return []; if (!Array.isArray(rawBinding)) return [];
const deduped: string[] = []; const deduped: string[] = [];
+45
View File
@@ -5,6 +5,8 @@ import { FIXED_TIMELINE_END_YEAR, FIXED_TIMELINE_START_YEAR, clampYearValue } fr
type Props = { type Props = {
year: number; year: number;
onYearChange: (year: number) => void; onYearChange: (year: number) => void;
timeRange?: number;
onTimeRangeChange?: (range: number) => void;
isLoading: boolean; isLoading: boolean;
disabled: boolean; disabled: boolean;
statusText?: string | null; statusText?: string | null;
@@ -15,6 +17,8 @@ type Props = {
export default function TimelineBar({ export default function TimelineBar({
year, year,
onYearChange, onYearChange,
timeRange,
onTimeRangeChange,
isLoading, isLoading,
disabled, disabled,
statusText, statusText,
@@ -34,6 +38,12 @@ export default function TimelineBar({
onYearChange(clampYearValue(Math.trunc(nextYear), lower, upper)); 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 ( return (
<div <div
style={{ style={{
@@ -148,6 +158,41 @@ export default function TimelineBar({
outline: "none", outline: "none",
}} }}
/> />
{typeof timeRange === "number" && onTimeRangeChange ? (
<label
title="time_range (0-30)"
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
color: "#94a3b8",
whiteSpace: "nowrap",
opacity: effectiveDisabled ? 0.6 : 1,
}}
>
<span style={{ fontSize: "12px" }}>Range</span>
<input
type="number"
min={0}
max={30}
step={1}
value={Math.max(0, Math.min(30, Math.trunc(timeRange)))}
onChange={(event) => handleTimeRangeChange(Number(event.target.value))}
disabled={effectiveDisabled}
aria-label="Timeline range"
style={{
width: "84px",
border: "1px solid rgba(148, 163, 184, 0.45)",
borderRadius: "6px",
padding: "6px 8px",
background: "rgba(15, 23, 42, 0.7)",
color: "#f8fafc",
fontSize: "13px",
outline: "none",
}}
/>
</label>
) : null}
</div> </div>
</div> </div>
); );
+8 -1
View File
@@ -7,6 +7,14 @@ export type GeoTypeMapRow = {
const MAP_ROWS: GeoTypeMapRow[] = rows as 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<string, number>(); const CODE_BY_KEY = new Map<string, number>();
const KEY_BY_CODE = new Map<number, string>(); const KEY_BY_CODE = new Map<number, string>();
@@ -31,4 +39,3 @@ export function geoTypeCodeToTypeKey(code: number | null | undefined): string |
if (!Number.isFinite(code)) return null; if (!Number.isFinite(code)) return null;
return KEY_BY_CODE.get(Math.trunc(code)) ?? null; return KEY_BY_CODE.get(Math.trunc(code)) ?? null;
} }
+2
View File
@@ -14,5 +14,7 @@ export type GeometriesBBoxQuery = {
maxLng: number; maxLng: number;
maxLat: number; maxLat: number;
time?: number; time?: number;
// New API: time_range (0-30) to expand the timeline query window.
timeRange?: number;
entity_id?: string; entity_id?: string;
}; };