485 lines
18 KiB
TypeScript
485 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import Map from "@/components/Map";
|
|
import BackgroundLayersPanel from "@/components/BackgroundLayersPanel";
|
|
import TimelineBar from "@/components/TimelineBar";
|
|
import { fetchGeometriesByBBox } from "@/api/geometries";
|
|
import { ApiError } from "@/api/http";
|
|
import { findEntityTypeOption } from "@/lib/entityTypeOptions";
|
|
import {
|
|
BACKGROUND_LAYER_OPTIONS,
|
|
BackgroundLayerVisibility,
|
|
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
|
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
|
} from "@/lib/backgroundLayers";
|
|
import { Feature, FeatureCollection } from "@/lib/useEditorState";
|
|
|
|
const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] };
|
|
const WORLD_BBOX = {
|
|
minLng: -180,
|
|
minLat: -90,
|
|
maxLng: 180,
|
|
maxLat: 90,
|
|
} as const;
|
|
const CURRENT_YEAR = new Date().getUTCFullYear();
|
|
const FALLBACK_TIMELINE_RANGE: TimelineRange = {
|
|
min: CURRENT_YEAR - 5000,
|
|
max: CURRENT_YEAR + 100,
|
|
};
|
|
const TIMELINE_DEBOUNCE_MS = 180;
|
|
const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1";
|
|
|
|
type TimelineRange = {
|
|
min: number;
|
|
max: number;
|
|
};
|
|
|
|
export default function Page() {
|
|
const [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FC);
|
|
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
|
|
const [timelineRange, setTimelineRange] = useState<TimelineRange>(FALLBACK_TIMELINE_RANGE);
|
|
const [timelineWindowStart, setTimelineWindowStart] = useState<number>(FALLBACK_TIMELINE_RANGE.min);
|
|
const [timelineWindowEnd, setTimelineWindowEnd] = useState<number>(FALLBACK_TIMELINE_RANGE.max);
|
|
const [timelineYear, setTimelineYear] = useState<number>(() =>
|
|
clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max)
|
|
);
|
|
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() =>
|
|
clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max)
|
|
);
|
|
const [isTimelineReady, setIsTimelineReady] = useState(false);
|
|
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
|
|
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
|
|
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
|
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
|
|
);
|
|
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
|
|
const timelineFetchRequestRef = useRef(0);
|
|
|
|
const selectedFeature =
|
|
selectedFeatureId === null
|
|
? null
|
|
: initialData.features.find((feature) =>
|
|
String(feature.properties.id) === String(selectedFeatureId)
|
|
) || null;
|
|
|
|
useEffect(() => {
|
|
if (selectedFeatureId === null) return;
|
|
const stillExists = initialData.features.some((feature) =>
|
|
String(feature.properties.id) === String(selectedFeatureId)
|
|
);
|
|
if (!stillExists) {
|
|
setSelectedFeatureId(null);
|
|
}
|
|
}, [initialData, selectedFeatureId]);
|
|
|
|
useEffect(() => {
|
|
let disposed = false;
|
|
|
|
async function loadTimelineBounds() {
|
|
setIsTimelineLoading(true);
|
|
try {
|
|
const data = await fetchGeometriesByBBox({
|
|
...WORLD_BBOX,
|
|
});
|
|
if (disposed) return;
|
|
|
|
const range = deriveTimelineRange(data);
|
|
const initialYear = clampYear(CURRENT_YEAR, range);
|
|
|
|
setTimelineRange(range);
|
|
setTimelineWindowStart(range.min);
|
|
setTimelineWindowEnd(range.max);
|
|
setTimelineYear(initialYear);
|
|
setTimelineDraftYear(initialYear);
|
|
setTimelineStatus(null);
|
|
} catch (err) {
|
|
if (disposed) return;
|
|
console.error("Load timeline bounds failed", err);
|
|
const fallbackYear = clampYear(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE);
|
|
setTimelineRange(FALLBACK_TIMELINE_RANGE);
|
|
setTimelineWindowStart(FALLBACK_TIMELINE_RANGE.min);
|
|
setTimelineWindowEnd(FALLBACK_TIMELINE_RANGE.max);
|
|
setTimelineYear(fallbackYear);
|
|
setTimelineDraftYear(fallbackYear);
|
|
setTimelineStatus("Không thể lấy phạm vi thời gian, đang dùng mốc mặc định.");
|
|
} finally {
|
|
if (!disposed) {
|
|
setIsTimelineLoading(false);
|
|
setIsTimelineReady(true);
|
|
}
|
|
}
|
|
}
|
|
|
|
loadTimelineBounds();
|
|
|
|
return () => {
|
|
disposed = true;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isTimelineReady) return;
|
|
|
|
const timeoutId = window.setTimeout(() => {
|
|
if (timelineDraftYear !== timelineYear) {
|
|
setTimelineYear(timelineDraftYear);
|
|
}
|
|
}, TIMELINE_DEBOUNCE_MS);
|
|
|
|
return () => window.clearTimeout(timeoutId);
|
|
}, [timelineDraftYear, timelineYear, isTimelineReady]);
|
|
|
|
useEffect(() => {
|
|
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
|
|
setIsBackgroundVisibilityReady(true);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const lower = Math.min(timelineWindowStart, timelineWindowEnd);
|
|
const upper = Math.max(timelineWindowStart, timelineWindowEnd);
|
|
setTimelineDraftYear((prev) => clampYearValue(prev, lower, upper));
|
|
}, [timelineWindowStart, timelineWindowEnd]);
|
|
|
|
useEffect(() => {
|
|
if (!isTimelineReady) return;
|
|
|
|
let disposed = false;
|
|
const requestId = ++timelineFetchRequestRef.current;
|
|
|
|
async function loadByTimeline() {
|
|
setIsTimelineLoading(true);
|
|
setTimelineStatus(null);
|
|
|
|
try {
|
|
const data = await fetchGeometriesByBBox({
|
|
...WORLD_BBOX,
|
|
time: timelineYear,
|
|
});
|
|
|
|
if (disposed || requestId !== timelineFetchRequestRef.current) return;
|
|
setInitialData(data);
|
|
} catch (err) {
|
|
if (err instanceof ApiError) {
|
|
console.error("Load timeline data failed", err.body);
|
|
} else {
|
|
console.error("Load timeline data failed", err);
|
|
}
|
|
|
|
if (!disposed && requestId === timelineFetchRequestRef.current) {
|
|
setTimelineStatus("Không tải được geometry tại mốc thời gian đã chọn.");
|
|
}
|
|
} finally {
|
|
if (!disposed && requestId === timelineFetchRequestRef.current) {
|
|
setIsTimelineLoading(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
loadByTimeline();
|
|
|
|
return () => {
|
|
disposed = true;
|
|
};
|
|
}, [timelineYear, isTimelineReady]);
|
|
|
|
const updateBackgroundVisibility = (
|
|
updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility
|
|
) => {
|
|
setBackgroundVisibility((prev) => {
|
|
const next = updater(prev);
|
|
persistBackgroundLayerVisibility(next);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleToggleBackgroundLayer = (id: (typeof BACKGROUND_LAYER_OPTIONS)[number]["id"]) => {
|
|
updateBackgroundVisibility((prev) => ({
|
|
...prev,
|
|
[id]: !prev[id],
|
|
}));
|
|
};
|
|
|
|
const handleShowAllBackgroundLayers = () => {
|
|
updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }));
|
|
};
|
|
|
|
const handleHideAllBackgroundLayers = () => {
|
|
updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }));
|
|
};
|
|
|
|
const handleTimelineWindowStartChange = (nextYear: number) => {
|
|
const next = clampYearValue(Math.trunc(nextYear), timelineRange.min, timelineRange.max);
|
|
setTimelineWindowStart(Math.min(next, timelineWindowEnd));
|
|
};
|
|
|
|
const handleTimelineWindowEndChange = (nextYear: number) => {
|
|
const next = clampYearValue(Math.trunc(nextYear), timelineRange.min, timelineRange.max);
|
|
setTimelineWindowEnd(Math.max(next, timelineWindowStart));
|
|
};
|
|
|
|
const handleTimelineYearChange = (nextYear: number) => {
|
|
const lower = Math.min(timelineWindowStart, timelineWindowEnd);
|
|
const upper = Math.max(timelineWindowStart, timelineWindowEnd);
|
|
setTimelineDraftYear(clampYearValue(Math.trunc(nextYear), lower, upper));
|
|
};
|
|
|
|
const timelineDisabled = !isTimelineReady;
|
|
const selectedType = resolveSelectedTypeSummary(selectedFeature);
|
|
const selectedTimeSummary = formatSelectedTimeSummary(selectedFeature);
|
|
const selectedEntitySummary = formatSelectedEntitySummary(selectedFeature);
|
|
const selectedBindingSummary = formatSelectedBindingSummary(selectedFeature);
|
|
|
|
return (
|
|
<div style={{ display: "flex", minHeight: "100vh" }}>
|
|
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
|
|
{isBackgroundVisibilityReady ? (
|
|
<Map
|
|
mode="select"
|
|
draft={initialData}
|
|
selectedFeatureId={selectedFeatureId}
|
|
onSelectFeatureId={setSelectedFeatureId}
|
|
backgroundVisibility={backgroundVisibility}
|
|
allowGeometryEditing={false}
|
|
respectBindingFilter={false}
|
|
/>
|
|
) : (
|
|
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
|
|
)}
|
|
<TimelineBar
|
|
minYear={timelineRange.min}
|
|
maxYear={timelineRange.max}
|
|
windowStartYear={timelineWindowStart}
|
|
windowEndYear={timelineWindowEnd}
|
|
onWindowStartYearChange={handleTimelineWindowStartChange}
|
|
onWindowEndYearChange={handleTimelineWindowEndChange}
|
|
year={timelineDraftYear}
|
|
onYearChange={handleTimelineYearChange}
|
|
isLoading={isTimelineLoading}
|
|
disabled={timelineDisabled}
|
|
statusText={timelineStatus}
|
|
/>
|
|
</div>
|
|
|
|
<BackgroundLayersPanel
|
|
visibility={backgroundVisibility}
|
|
onToggleLayer={handleToggleBackgroundLayer}
|
|
onShowAll={handleShowAllBackgroundLayers}
|
|
onHideAll={handleHideAllBackgroundLayers}
|
|
topContent={
|
|
<div
|
|
style={{
|
|
padding: "10px",
|
|
background: "#0b1220",
|
|
borderRadius: "8px",
|
|
border: "1px solid #1f2937",
|
|
display: "grid",
|
|
gap: "8px",
|
|
}}
|
|
>
|
|
<div style={{ fontWeight: 700, fontSize: "14px", color: "#f8fafc" }}>
|
|
Viewer
|
|
</div>
|
|
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
|
Click trực tiếp trên map để chọn geometry.
|
|
</div>
|
|
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
|
|
{selectedFeature ? `ID: ${String(selectedFeature.properties.id)}` : "Chưa chọn geometry"}
|
|
</div>
|
|
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
|
|
Type: {selectedType}
|
|
</div>
|
|
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
|
|
Time: {selectedTimeSummary}
|
|
</div>
|
|
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
|
|
Entities: {selectedEntitySummary}
|
|
</div>
|
|
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
|
|
Binding: {selectedBindingSummary}
|
|
</div>
|
|
|
|
<Link
|
|
href="/editor"
|
|
style={{
|
|
marginTop: "4px",
|
|
display: "inline-flex",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
borderRadius: "6px",
|
|
padding: "7px 8px",
|
|
background: "#1d4ed8",
|
|
color: "#ffffff",
|
|
textDecoration: "none",
|
|
fontSize: "13px",
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
Mở Editor
|
|
</Link>
|
|
</div>
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function deriveTimelineRange(collection: FeatureCollection): TimelineRange {
|
|
let min = Number.POSITIVE_INFINITY;
|
|
let max = Number.NEGATIVE_INFINITY;
|
|
|
|
for (const feature of collection.features) {
|
|
const { time_start, time_end } = feature.properties;
|
|
|
|
if (isYearNumber(time_start)) {
|
|
min = Math.min(min, time_start);
|
|
max = Math.max(max, time_start);
|
|
}
|
|
|
|
if (isYearNumber(time_end)) {
|
|
min = Math.min(min, time_end);
|
|
max = Math.max(max, time_end);
|
|
}
|
|
}
|
|
|
|
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
|
return FALLBACK_TIMELINE_RANGE;
|
|
}
|
|
|
|
return {
|
|
min: Math.floor(min),
|
|
max: Math.ceil(max),
|
|
};
|
|
}
|
|
|
|
function clampYear(year: number, range: TimelineRange): number {
|
|
return clampYearValue(year, range.min, range.max);
|
|
}
|
|
|
|
function clampYearValue(year: number, minYear: number, maxYear: number): number {
|
|
const lower = Math.min(minYear, maxYear);
|
|
const upper = Math.max(minYear, maxYear);
|
|
if (year < lower) return lower;
|
|
if (year > upper) return upper;
|
|
return year;
|
|
}
|
|
|
|
function isYearNumber(value: number | null | undefined): value is number {
|
|
return typeof value === "number" && Number.isFinite(value);
|
|
}
|
|
|
|
function formatSelectedTimeSummary(feature: Feature | null): string {
|
|
if (!feature) return "Không có";
|
|
const start = isYearNumber(feature.properties.time_start)
|
|
? String(feature.properties.time_start)
|
|
: "?";
|
|
const end = isYearNumber(feature.properties.time_end)
|
|
? String(feature.properties.time_end)
|
|
: "?";
|
|
return `${start} → ${end}`;
|
|
}
|
|
|
|
function formatSelectedEntitySummary(feature: Feature | null): string {
|
|
if (!feature) return "Không có";
|
|
|
|
const names = Array.isArray(feature.properties.entity_names)
|
|
? feature.properties.entity_names.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
|
|
: [];
|
|
if (names.length) return names.join(", ");
|
|
|
|
if (typeof feature.properties.entity_name === "string" && feature.properties.entity_name.trim().length > 0) {
|
|
return feature.properties.entity_name.trim();
|
|
}
|
|
|
|
const ids = Array.isArray(feature.properties.entity_ids)
|
|
? feature.properties.entity_ids.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
|
|
: [];
|
|
if (ids.length) return ids.join(", ");
|
|
|
|
if (typeof feature.properties.entity_id === "string" && feature.properties.entity_id.trim().length > 0) {
|
|
return feature.properties.entity_id.trim();
|
|
}
|
|
|
|
return "Chưa gắn";
|
|
}
|
|
|
|
function formatSelectedBindingSummary(feature: Feature | null): string {
|
|
if (!feature) return "Không có";
|
|
const binding = Array.isArray(feature.properties.binding)
|
|
? feature.properties.binding
|
|
.map((item) => {
|
|
if (typeof item !== "string" && typeof item !== "number") return "";
|
|
return String(item).trim();
|
|
})
|
|
.filter((item) => item.length > 0)
|
|
: [];
|
|
if (!binding.length) return "Không có";
|
|
return binding.join(", ");
|
|
}
|
|
|
|
function resolveSelectedTypeSummary(feature: Feature | null): string {
|
|
if (!feature) return "Không có";
|
|
const typeId = normalizeTypeId(feature.properties.type) || normalizeTypeId(feature.properties.entity_type_id);
|
|
if (!typeId) return "Không có";
|
|
const option = findEntityTypeOption(typeId);
|
|
if (!option) return typeId;
|
|
return `${option.label} (${option.groupLabel})`;
|
|
}
|
|
|
|
function normalizeTypeId(value: unknown): string | null {
|
|
if (typeof value !== "string") return null;
|
|
const normalized = value.trim().toLowerCase();
|
|
return normalized.length ? normalized : null;
|
|
}
|
|
|
|
function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility {
|
|
if (typeof window === "undefined") {
|
|
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
|
}
|
|
|
|
try {
|
|
const raw = window.localStorage.getItem(BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY);
|
|
if (!raw) {
|
|
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
|
}
|
|
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
const normalized = normalizeBackgroundLayerVisibility(parsed);
|
|
return normalized || { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
|
} catch (err) {
|
|
console.warn("Load background layer visibility from storage failed", err);
|
|
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
|
}
|
|
}
|
|
|
|
function persistBackgroundLayerVisibility(visibility: BackgroundLayerVisibility) {
|
|
if (typeof window === "undefined") return;
|
|
try {
|
|
window.localStorage.setItem(
|
|
BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY,
|
|
JSON.stringify(visibility)
|
|
);
|
|
} catch (err) {
|
|
console.warn("Persist background layer visibility failed", err);
|
|
}
|
|
}
|
|
|
|
function normalizeBackgroundLayerVisibility(raw: unknown): BackgroundLayerVisibility | null {
|
|
if (!raw || typeof raw !== "object") return null;
|
|
|
|
const source = raw as Record<string, unknown>;
|
|
const next: BackgroundLayerVisibility = {
|
|
...DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
|
};
|
|
|
|
for (const layer of BACKGROUND_LAYER_OPTIONS) {
|
|
const value = source[layer.id];
|
|
if (typeof value === "boolean") {
|
|
next[layer.id] = value;
|
|
}
|
|
}
|
|
|
|
return next;
|
|
}
|