refactor: reoute / splited
This commit is contained in:
@@ -7,11 +7,12 @@ import type { MapHoverPopupContent } from "@/uhm/components/map/useMapHoverPopup
|
||||
import PresentPlaceSearch, { type HistoricalGeometryFocusPayload, type PresentPlaceSelection } from "@/uhm/components/editor/PresentPlaceSearch";
|
||||
import ReplayPreviewOverlay from "@/uhm/components/editor/ReplayPreviewOverlay";
|
||||
import ReplayPreviewLayerPanel from "@/uhm/components/editor/ReplayPreviewLayerPanel";
|
||||
import { PublicMapZoomPanel } from "@/uhm/components/preview/PublicPreviewClientPage";
|
||||
import { PublicMapZoomPanel } from "@/uhm/components/preview/PublicMapZoomPanel";
|
||||
import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar";
|
||||
import TimelineBar from "@/uhm/components/ui/TimelineBar";
|
||||
import RelatedEntityPopup from "./RelatedEntityPopup";
|
||||
import WikiSelectionPanel from "./WikiSelectionPanel";
|
||||
import { cleanWikiPreviewQuote, extractWikiBlockquoteText } from "@/uhm/lib/preview/previewUtils";
|
||||
import { fitMapToFeatureCollection } from "@/uhm/components/map/mapUtils";
|
||||
|
||||
import { fetchWikiById, type Wiki } from "@/uhm/api/wikis";
|
||||
@@ -990,42 +991,7 @@ function EditorPreviewModePanel({
|
||||
// Helper functions
|
||||
// ==========================================
|
||||
|
||||
function extractWikiBlockquoteText(content: string | null | undefined): string {
|
||||
if (!content) return "";
|
||||
|
||||
const decoded = decodeHtmlEntities(content);
|
||||
const blockquoteMatch = decoded.match(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/i);
|
||||
const rawText = blockquoteMatch?.[1]?.trim() || "";
|
||||
if (!rawText) return "";
|
||||
|
||||
return cleanWikiPlainText(rawText);
|
||||
}
|
||||
|
||||
function cleanWikiPreviewQuote(raw: string | null | undefined): string {
|
||||
const decoded = decodeHtmlEntities(String(raw || ""));
|
||||
const blockquote = extractWikiBlockquoteText(decoded);
|
||||
return cleanWikiPlainText(blockquote || decoded);
|
||||
}
|
||||
|
||||
function cleanWikiPlainText(raw: string): string {
|
||||
return decodeHtmlEntities(raw)
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/\u00a0/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(raw: string): string {
|
||||
return raw
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/gi, "'");
|
||||
}
|
||||
|
||||
function getWikiHoverTitle(wiki: { title?: string | null } | null | undefined, fallbackTitle: string): string {
|
||||
return String(wiki?.title || "").trim() || fallbackTitle;
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import type { MapHandle } from "@/uhm/components/Map";
|
||||
import { MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "@/uhm/lib/map/constants";
|
||||
|
||||
export function PublicMapZoomPanel({
|
||||
mapHandleRef,
|
||||
onPlayPreviewReplay,
|
||||
onResumePreviewReplay,
|
||||
onStopPreviewReplay,
|
||||
}: {
|
||||
mapHandleRef: RefObject<MapHandle | null>;
|
||||
onPlayPreviewReplay?: () => void;
|
||||
onResumePreviewReplay?: () => void;
|
||||
onStopPreviewReplay?: () => void;
|
||||
}) {
|
||||
const [zoomLevel, setZoomLevel] = useState(2);
|
||||
const [isGlobeProjection, setIsGlobeProjection] = useState(false);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | null = null;
|
||||
let retryTimer: number | null = null;
|
||||
|
||||
const bind = () => {
|
||||
if (disposed) return;
|
||||
const map = mapHandleRef.current?.getMap();
|
||||
if (!map) {
|
||||
retryTimer = window.setTimeout(bind, 120);
|
||||
return;
|
||||
}
|
||||
|
||||
const syncProjection = () => {
|
||||
const projection = mapHandleRef.current?.getViewState()?.projection;
|
||||
setIsGlobeProjection(projection === "globe");
|
||||
};
|
||||
|
||||
const syncZoom = () => {
|
||||
if (isDraggingRef.current) return;
|
||||
setZoomLevel(roundPanelZoom(map.getZoom()));
|
||||
};
|
||||
|
||||
syncZoom();
|
||||
syncProjection();
|
||||
map.on("zoom", syncZoom);
|
||||
map.on("zoomend", syncZoom);
|
||||
map.on("styledata", syncProjection);
|
||||
cleanup = () => {
|
||||
map.off("zoom", syncZoom);
|
||||
map.off("zoomend", syncZoom);
|
||||
map.off("styledata", syncProjection);
|
||||
};
|
||||
};
|
||||
|
||||
bind();
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (retryTimer) window.clearTimeout(retryTimer);
|
||||
cleanup?.();
|
||||
};
|
||||
}, [mapHandleRef]);
|
||||
|
||||
const toggleProjection = () => {
|
||||
const next = !isGlobeProjection;
|
||||
setIsGlobeProjection(next);
|
||||
mapHandleRef.current?.setGlobeProjection(next);
|
||||
};
|
||||
|
||||
const zoomByStep = (delta: number) => {
|
||||
const map = mapHandleRef.current?.getMap();
|
||||
if (!map) return;
|
||||
const next = clampZoom(zoomLevel + delta);
|
||||
setZoomLevel(next);
|
||||
map.easeTo({ zoom: next, duration: 120 });
|
||||
};
|
||||
|
||||
const handleSliderChange = (nextRaw: number) => {
|
||||
const map = mapHandleRef.current?.getMap();
|
||||
if (!map || !Number.isFinite(nextRaw)) return;
|
||||
const next = clampZoom(nextRaw);
|
||||
setZoomLevel(next);
|
||||
map.jumpTo({ zoom: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
flexShrink: 0,
|
||||
minWidth: 0,
|
||||
background: "linear-gradient(135deg, rgba(30, 30, 30, 0.72) 0%, rgba(20, 20, 20, 0.85) 100%)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
borderRadius: 50,
|
||||
padding: "8px 14px",
|
||||
color: "#f8fafc",
|
||||
boxShadow: "0 10px 30px -10px rgba(0, 0, 0, 0.5), inset 0 1px 1px 0 rgba(255, 255, 255, 0.05)",
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
<style jsx>{`
|
||||
.uhm-public-zoom-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
user-select: none;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.uhm-public-zoom-btn:hover {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.uhm-public-zoom-btn:active {
|
||||
background: rgba(16, 185, 129, 0.25);
|
||||
border-color: #10b981;
|
||||
}
|
||||
.uhm-public-zoom-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: clamp(72px, 12vw, 132px);
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
flex: 1 1 72px;
|
||||
min-width: 0;
|
||||
}
|
||||
.uhm-public-zoom-slider::-webkit-slider-runnable-track {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.uhm-public-zoom-slider:hover::-webkit-slider-runnable-track {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.uhm-public-zoom-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
margin-top: -6px;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 30% 30%, #34d399 0%, #059669 100%);
|
||||
border: 1.5px solid #ffffff;
|
||||
box-shadow: 0 0 10px rgba(16, 185, 129, 0.4), 0 3px 6px rgba(0, 0, 0, 0.15), inset 0 1px 1px rgba(255, 255, 255, 0.4);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.15s ease;
|
||||
}
|
||||
.uhm-public-zoom-slider:hover::-webkit-slider-thumb {
|
||||
transform: scale(1.2);
|
||||
box-shadow: 0 0 15px rgba(16, 185, 129, 0.6), 0 5px 10px rgba(0, 0, 0, 0.18), inset 0 1px 1px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.uhm-public-projection-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
padding: 0 2px 0 0;
|
||||
user-select: none;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.uhm-public-projection-track {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
position: relative;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.uhm-public-projection-track.active {
|
||||
background: rgba(52, 211, 153, 0.35);
|
||||
border-color: rgba(16, 185, 129, 0.6);
|
||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.35), inset 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.uhm-public-projection-thumb {
|
||||
position: absolute;
|
||||
top: 1.5px;
|
||||
left: 2px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.uhm-public-projection-track.active .uhm-public-projection-thumb {
|
||||
left: 19px;
|
||||
background: #34d399;
|
||||
box-shadow: 0 0 10px rgba(52, 211, 153, 0.6), 0 2px 4px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.uhm-public-projection-label {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
font-weight: 700;
|
||||
min-width: 40px;
|
||||
text-align: left;
|
||||
transition: color 0.25s ease;
|
||||
}
|
||||
.uhm-public-projection-label.active {
|
||||
color: #ffffff;
|
||||
}
|
||||
.uhm-public-play-btn {
|
||||
width: auto;
|
||||
min-width: 64px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(56, 189, 248, 0.4);
|
||||
background: rgba(56, 189, 248, 0.15);
|
||||
color: #38bdf8;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
user-select: none;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.uhm-public-play-btn:hover {
|
||||
border-color: rgba(56, 189, 248, 0.65);
|
||||
background: rgba(56, 189, 248, 0.24);
|
||||
color: #7dd3fc;
|
||||
}
|
||||
.uhm-public-play-btn.stop {
|
||||
border-color: rgba(248, 113, 113, 0.45);
|
||||
background: rgba(127, 29, 29, 0.45);
|
||||
color: #fecaca;
|
||||
}
|
||||
.uhm-public-play-btn.stop:hover {
|
||||
border-color: rgba(248, 113, 113, 0.75);
|
||||
background: rgba(153, 27, 27, 0.62);
|
||||
color: #ffffff;
|
||||
}
|
||||
.uhm-public-play-btn.resume {
|
||||
border-color: rgba(34, 197, 94, 0.45);
|
||||
background: rgba(22, 101, 52, 0.45);
|
||||
color: #bbf7d0;
|
||||
}
|
||||
.uhm-public-play-btn.resume:hover {
|
||||
border-color: rgba(34, 197, 94, 0.75);
|
||||
background: rgba(22, 163, 74, 0.5);
|
||||
color: #ffffff;
|
||||
}
|
||||
.uhm-public-play-icon {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 5px solid transparent;
|
||||
border-bottom: 5px solid transparent;
|
||||
border-left: 8px solid currentColor;
|
||||
}
|
||||
.uhm-public-stop-icon {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 2px;
|
||||
background: currentColor;
|
||||
}
|
||||
`}</style>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleProjection}
|
||||
className="uhm-public-projection-toggle"
|
||||
aria-label="Chuyển chế độ hiển thị hình cầu"
|
||||
title={isGlobeProjection ? "Đang ở chế độ hình cầu" : "Đang ở chế độ bản đồ phẳng"}
|
||||
>
|
||||
<span className={`uhm-public-projection-track ${isGlobeProjection ? "active" : ""}`}>
|
||||
<span className="uhm-public-projection-thumb" />
|
||||
</span>
|
||||
<span className={`uhm-public-projection-label ${isGlobeProjection ? "active" : ""}`}>
|
||||
{isGlobeProjection ? "Cầu" : "Phẳng"}
|
||||
</span>
|
||||
</button>
|
||||
{onPlayPreviewReplay ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPlayPreviewReplay}
|
||||
className="uhm-public-play-btn"
|
||||
aria-label="Phát diễn biến đã chọn"
|
||||
title="Phát diễn biến của hình đang chọn"
|
||||
>
|
||||
<span aria-hidden="true" className="uhm-public-play-icon" />
|
||||
Phát
|
||||
</button>
|
||||
) : null}
|
||||
{onResumePreviewReplay ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResumePreviewReplay}
|
||||
className="uhm-public-play-btn resume"
|
||||
aria-label="Tiếp tục diễn biến đã chọn"
|
||||
title="Tiếp tục diễn biến đang tạm dừng"
|
||||
>
|
||||
<span aria-hidden="true" className="uhm-public-play-icon" />
|
||||
Tiếp tục
|
||||
</button>
|
||||
) : null}
|
||||
{onStopPreviewReplay ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStopPreviewReplay}
|
||||
className="uhm-public-play-btn stop"
|
||||
aria-label="Dừng diễn biến đã chọn"
|
||||
title="Dừng diễn biến đang phát"
|
||||
>
|
||||
<span aria-hidden="true" className="uhm-public-stop-icon" />
|
||||
Dừng
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomByStep(-0.8)}
|
||||
className="uhm-public-zoom-btn"
|
||||
aria-label="Thu nhỏ bản đồ"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={MAP_MIN_ZOOM}
|
||||
max={MAP_MAX_ZOOM}
|
||||
step={0.1}
|
||||
value={zoomLevel}
|
||||
className="uhm-public-zoom-slider"
|
||||
onPointerDown={() => {
|
||||
isDraggingRef.current = true;
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
isDraggingRef.current = false;
|
||||
const map = mapHandleRef.current?.getMap();
|
||||
if (map) setZoomLevel(roundPanelZoom(map.getZoom()));
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
isDraggingRef.current = false;
|
||||
}}
|
||||
onBlur={() => {
|
||||
isDraggingRef.current = false;
|
||||
}}
|
||||
onChange={(event) => handleSliderChange(Number(event.target.value))}
|
||||
aria-label="Mức thu phóng bản đồ"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomByStep(0.8)}
|
||||
className="uhm-public-zoom-btn"
|
||||
aria-label="Phóng to bản đồ"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
minWidth: 48,
|
||||
textAlign: "right",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "#94a3b8",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
{zoomLevel.toFixed(1)}x
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function clampZoom(value: number): number {
|
||||
if (!Number.isFinite(value)) return MAP_MIN_ZOOM;
|
||||
return Math.max(MAP_MIN_ZOOM, Math.min(MAP_MAX_ZOOM, value));
|
||||
}
|
||||
|
||||
function roundPanelZoom(value: number): number {
|
||||
if (!Number.isFinite(value)) return MAP_MIN_ZOOM;
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import { usePublicPreviewData } from "@/uhm/components/preview/hooks/usePublicPr
|
||||
import {
|
||||
fetchEntitiesByWikiIds,
|
||||
fetchGeometriesByEntityIds,
|
||||
type RelationGeometry,
|
||||
} from "@/uhm/api/relations";
|
||||
import { useReplayPreview } from "@/uhm/lib/replay/useReplayPreview";
|
||||
import type { MapFeaturePayload, MapHandle } from "@/uhm/components/Map";
|
||||
@@ -29,10 +28,8 @@ import PresentPlaceSearch, {
|
||||
type PresentPlaceSelection,
|
||||
} from "@/uhm/components/editor/PresentPlaceSearch";
|
||||
import type { Entity } from "@/uhm/api/entities";
|
||||
import { reverseGeocodePresentPlace } from "@/uhm/api/goongPlaces";
|
||||
import { fetchWikiBySlug, type Wiki } from "@/uhm/api/wikis";
|
||||
import type { FeatureCollection } from "@/uhm/types/geo";
|
||||
import { getGeometryRepresentativePoint } from "@/uhm/components/map/mapUtils";
|
||||
import {
|
||||
type BackgroundLayerId,
|
||||
type BackgroundLayerVisibility,
|
||||
@@ -43,8 +40,21 @@ import {
|
||||
persistBackgroundLayerVisibility,
|
||||
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
|
||||
import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap";
|
||||
import { MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "@/uhm/lib/map/constants";
|
||||
import { clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/uhm/lib/utils/timeline";
|
||||
import RelatedEntityPopup from "@/uhm/components/preview/RelatedEntityPopup";
|
||||
import { PublicMapZoomPanel } from "@/uhm/components/preview/PublicMapZoomPanel";
|
||||
import {
|
||||
cleanWikiPreviewQuote,
|
||||
extractWikiBlockquoteText,
|
||||
buildGeometrySelectionRows,
|
||||
filterRelationGeometriesByEarliestStartTime,
|
||||
relationGeometriesToFeatureCollection,
|
||||
getEntityPreferredTimeStart,
|
||||
findRelationWikiBySlug,
|
||||
findRelationEntityIdsByWikiSlug,
|
||||
cloneStringArrayRecord,
|
||||
appendUnique,
|
||||
} from "@/uhm/lib/preview/previewUtils";
|
||||
|
||||
const CURRENT_YEAR = new Date().getUTCFullYear();
|
||||
|
||||
@@ -920,48 +930,22 @@ export default function PublicPreviewClientPage({
|
||||
)}
|
||||
|
||||
{linkEntityPopup ? (
|
||||
<div
|
||||
ref={linkEntityPopupRef}
|
||||
className="fixed z-[60] w-[240px] overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-800 dark:bg-gray-950"
|
||||
style={{ top: linkEntityPopup.top, left: linkEntityPopup.left }}
|
||||
>
|
||||
<div className="border-b border-gray-200 px-3 py-2 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Related Entities
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
/wiki/{linkEntityPopup.slug}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[220px] overflow-y-auto p-2">
|
||||
{linkEntityPopup.entities.length ? (
|
||||
<div className="grid gap-1">
|
||||
{linkEntityPopup.entities.map((entity) => (
|
||||
<button
|
||||
key={entity.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
<RelatedEntityPopup
|
||||
slug={linkEntityPopup.slug}
|
||||
entities={linkEntityPopup.entities}
|
||||
top={linkEntityPopup.top}
|
||||
left={linkEntityPopup.left}
|
||||
onClose={() => setLinkEntityPopup(null)}
|
||||
onSelectEntity={(entityId) => {
|
||||
if (replayMode !== "idle") {
|
||||
handleExitReplay();
|
||||
}
|
||||
setWikiSelectionPanelAnchor(null);
|
||||
setRightPanelMode("wiki");
|
||||
selectEntity(entity.id, { preferredWikiSlug: linkEntityPopup.slug });
|
||||
selectEntity(entityId, { preferredWikiSlug: linkEntityPopup.slug });
|
||||
setLinkEntityPopup(null);
|
||||
}}
|
||||
className="rounded-lg px-3 py-2 text-left text-sm text-gray-700 transition hover:bg-gray-50 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-white/[0.04] dark:hover:text-white"
|
||||
>
|
||||
{entity.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Không có entity liên quan.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isGeometryChooserOpen && geometrySelectionPanel ? (
|
||||
@@ -1042,595 +1026,3 @@ export default function PublicPreviewClientPage({
|
||||
);
|
||||
}
|
||||
|
||||
export function PublicMapZoomPanel({
|
||||
mapHandleRef,
|
||||
onPlayPreviewReplay,
|
||||
onResumePreviewReplay,
|
||||
onStopPreviewReplay,
|
||||
}: {
|
||||
mapHandleRef: RefObject<MapHandle | null>;
|
||||
onPlayPreviewReplay?: () => void;
|
||||
onResumePreviewReplay?: () => void;
|
||||
onStopPreviewReplay?: () => void;
|
||||
}) {
|
||||
const [zoomLevel, setZoomLevel] = useState(2);
|
||||
const [isGlobeProjection, setIsGlobeProjection] = useState(false);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let cleanup: (() => void) | null = null;
|
||||
let retryTimer: number | null = null;
|
||||
|
||||
const bind = () => {
|
||||
if (disposed) return;
|
||||
const map = mapHandleRef.current?.getMap();
|
||||
if (!map) {
|
||||
retryTimer = window.setTimeout(bind, 120);
|
||||
return;
|
||||
}
|
||||
|
||||
const syncProjection = () => {
|
||||
const projection = mapHandleRef.current?.getViewState()?.projection;
|
||||
setIsGlobeProjection(projection === "globe");
|
||||
};
|
||||
|
||||
const syncZoom = () => {
|
||||
if (isDraggingRef.current) return;
|
||||
setZoomLevel(roundPanelZoom(map.getZoom()));
|
||||
};
|
||||
|
||||
syncZoom();
|
||||
syncProjection();
|
||||
map.on("zoom", syncZoom);
|
||||
map.on("zoomend", syncZoom);
|
||||
map.on("styledata", syncProjection);
|
||||
cleanup = () => {
|
||||
map.off("zoom", syncZoom);
|
||||
map.off("zoomend", syncZoom);
|
||||
map.off("styledata", syncProjection);
|
||||
};
|
||||
};
|
||||
|
||||
bind();
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (retryTimer) window.clearTimeout(retryTimer);
|
||||
cleanup?.();
|
||||
};
|
||||
}, [mapHandleRef]);
|
||||
|
||||
const toggleProjection = () => {
|
||||
const next = !isGlobeProjection;
|
||||
setIsGlobeProjection(next);
|
||||
mapHandleRef.current?.setGlobeProjection(next);
|
||||
};
|
||||
|
||||
const zoomByStep = (delta: number) => {
|
||||
const map = mapHandleRef.current?.getMap();
|
||||
if (!map) return;
|
||||
const next = clampZoom(zoomLevel + delta);
|
||||
setZoomLevel(next);
|
||||
map.easeTo({ zoom: next, duration: 120 });
|
||||
};
|
||||
|
||||
const handleSliderChange = (nextRaw: number) => {
|
||||
const map = mapHandleRef.current?.getMap();
|
||||
if (!map || !Number.isFinite(nextRaw)) return;
|
||||
const next = clampZoom(nextRaw);
|
||||
setZoomLevel(next);
|
||||
map.jumpTo({ zoom: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
flexShrink: 0,
|
||||
minWidth: 0,
|
||||
background: "linear-gradient(135deg, rgba(30, 30, 30, 0.72) 0%, rgba(20, 20, 20, 0.85) 100%)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
borderRadius: 50,
|
||||
padding: "8px 14px",
|
||||
color: "#f8fafc",
|
||||
boxShadow: "0 10px 30px -10px rgba(0, 0, 0, 0.5), inset 0 1px 1px 0 rgba(255, 255, 255, 0.05)",
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
<style jsx>{`
|
||||
.uhm-public-zoom-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
user-select: none;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.uhm-public-zoom-btn:hover {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.uhm-public-zoom-btn:active {
|
||||
background: rgba(16, 185, 129, 0.25);
|
||||
border-color: #10b981;
|
||||
}
|
||||
.uhm-public-zoom-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: clamp(72px, 12vw, 132px);
|
||||
height: 24px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
flex: 1 1 72px;
|
||||
min-width: 0;
|
||||
}
|
||||
.uhm-public-zoom-slider::-webkit-slider-runnable-track {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.uhm-public-zoom-slider:hover::-webkit-slider-runnable-track {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.uhm-public-zoom-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
margin-top: -6px;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 30% 30%, #34d399 0%, #059669 100%);
|
||||
border: 1.5px solid #ffffff;
|
||||
box-shadow: 0 0 10px rgba(16, 185, 129, 0.4), 0 3px 6px rgba(0, 0, 0, 0.15), inset 0 1px 1px rgba(255, 255, 255, 0.4);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.15s ease;
|
||||
}
|
||||
.uhm-public-zoom-slider:hover::-webkit-slider-thumb {
|
||||
transform: scale(1.2);
|
||||
box-shadow: 0 0 15px rgba(16, 185, 129, 0.6), 0 5px 10px rgba(0, 0, 0, 0.18), inset 0 1px 1px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.uhm-public-projection-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
padding: 0 2px 0 0;
|
||||
user-select: none;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.uhm-public-projection-track {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
position: relative;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.uhm-public-projection-track.active {
|
||||
background: rgba(52, 211, 153, 0.35);
|
||||
border-color: rgba(16, 185, 129, 0.6);
|
||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.35), inset 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.uhm-public-projection-thumb {
|
||||
position: absolute;
|
||||
top: 1.5px;
|
||||
left: 2px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.uhm-public-projection-track.active .uhm-public-projection-thumb {
|
||||
left: 19px;
|
||||
background: #34d399;
|
||||
box-shadow: 0 0 10px rgba(52, 211, 153, 0.6), 0 2px 4px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.uhm-public-projection-label {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
font-weight: 700;
|
||||
min-width: 40px;
|
||||
text-align: left;
|
||||
transition: color 0.25s ease;
|
||||
}
|
||||
.uhm-public-projection-label.active {
|
||||
color: #ffffff;
|
||||
}
|
||||
.uhm-public-play-btn {
|
||||
width: auto;
|
||||
min-width: 64px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(56, 189, 248, 0.4);
|
||||
background: rgba(56, 189, 248, 0.15);
|
||||
color: #38bdf8;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
user-select: none;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.uhm-public-play-btn:hover {
|
||||
border-color: rgba(56, 189, 248, 0.65);
|
||||
background: rgba(56, 189, 248, 0.24);
|
||||
color: #7dd3fc;
|
||||
}
|
||||
.uhm-public-play-btn.stop {
|
||||
border-color: rgba(248, 113, 113, 0.45);
|
||||
background: rgba(127, 29, 29, 0.45);
|
||||
color: #fecaca;
|
||||
}
|
||||
.uhm-public-play-btn.stop:hover {
|
||||
border-color: rgba(248, 113, 113, 0.75);
|
||||
background: rgba(153, 27, 27, 0.62);
|
||||
color: #ffffff;
|
||||
}
|
||||
.uhm-public-play-btn.resume {
|
||||
border-color: rgba(34, 197, 94, 0.45);
|
||||
background: rgba(22, 101, 52, 0.45);
|
||||
color: #bbf7d0;
|
||||
}
|
||||
.uhm-public-play-btn.resume:hover {
|
||||
border-color: rgba(34, 197, 94, 0.75);
|
||||
background: rgba(22, 163, 74, 0.5);
|
||||
color: #ffffff;
|
||||
}
|
||||
.uhm-public-play-icon {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 5px solid transparent;
|
||||
border-bottom: 5px solid transparent;
|
||||
border-left: 8px solid currentColor;
|
||||
}
|
||||
.uhm-public-stop-icon {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 2px;
|
||||
background: currentColor;
|
||||
}
|
||||
`}</style>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleProjection}
|
||||
className="uhm-public-projection-toggle"
|
||||
aria-label="Chuyển chế độ hiển thị hình cầu"
|
||||
title={isGlobeProjection ? "Đang ở chế độ hình cầu" : "Đang ở chế độ bản đồ phẳng"}
|
||||
>
|
||||
<span className={`uhm-public-projection-track ${isGlobeProjection ? "active" : ""}`}>
|
||||
<span className="uhm-public-projection-thumb" />
|
||||
</span>
|
||||
<span className={`uhm-public-projection-label ${isGlobeProjection ? "active" : ""}`}>
|
||||
{isGlobeProjection ? "Cầu" : "Phẳng"}
|
||||
</span>
|
||||
</button>
|
||||
{onPlayPreviewReplay ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPlayPreviewReplay}
|
||||
className="uhm-public-play-btn"
|
||||
aria-label="Phát diễn biến đã chọn"
|
||||
title="Phát diễn biến của hình đang chọn"
|
||||
>
|
||||
<span aria-hidden="true" className="uhm-public-play-icon" />
|
||||
Phát
|
||||
</button>
|
||||
) : null}
|
||||
{onResumePreviewReplay ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResumePreviewReplay}
|
||||
className="uhm-public-play-btn resume"
|
||||
aria-label="Tiếp tục diễn biến đã chọn"
|
||||
title="Tiếp tục diễn biến đang tạm dừng"
|
||||
>
|
||||
<span aria-hidden="true" className="uhm-public-play-icon" />
|
||||
Tiếp tục
|
||||
</button>
|
||||
) : null}
|
||||
{onStopPreviewReplay ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStopPreviewReplay}
|
||||
className="uhm-public-play-btn stop"
|
||||
aria-label="Dừng diễn biến đã chọn"
|
||||
title="Dừng diễn biến đang phát"
|
||||
>
|
||||
<span aria-hidden="true" className="uhm-public-stop-icon" />
|
||||
Dừng
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomByStep(-0.8)}
|
||||
className="uhm-public-zoom-btn"
|
||||
aria-label="Thu nhỏ bản đồ"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={MAP_MIN_ZOOM}
|
||||
max={MAP_MAX_ZOOM}
|
||||
step={0.1}
|
||||
value={zoomLevel}
|
||||
className="uhm-public-zoom-slider"
|
||||
onPointerDown={() => {
|
||||
isDraggingRef.current = true;
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
isDraggingRef.current = false;
|
||||
const map = mapHandleRef.current?.getMap();
|
||||
if (map) setZoomLevel(roundPanelZoom(map.getZoom()));
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
isDraggingRef.current = false;
|
||||
}}
|
||||
onBlur={() => {
|
||||
isDraggingRef.current = false;
|
||||
}}
|
||||
onChange={(event) => handleSliderChange(Number(event.target.value))}
|
||||
aria-label="Mức thu phóng bản đồ"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomByStep(0.8)}
|
||||
className="uhm-public-zoom-btn"
|
||||
aria-label="Phóng to bản đồ"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
minWidth: 48,
|
||||
textAlign: "right",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "#94a3b8",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
{zoomLevel.toFixed(1)}x
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function clampZoom(value: number): number {
|
||||
if (!Number.isFinite(value)) return MAP_MIN_ZOOM;
|
||||
return Math.max(MAP_MIN_ZOOM, Math.min(MAP_MAX_ZOOM, value));
|
||||
}
|
||||
|
||||
function roundPanelZoom(value: number): number {
|
||||
if (!Number.isFinite(value)) return MAP_MIN_ZOOM;
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
function cleanWikiPreviewQuote(raw: string | null | undefined): string {
|
||||
const decoded = decodeHtmlEntities(String(raw || ""));
|
||||
const blockquote = extractWikiBlockquoteText(decoded);
|
||||
return cleanWikiPlainText(blockquote || decoded);
|
||||
}
|
||||
|
||||
function extractWikiBlockquoteText(content: string | null | undefined): string {
|
||||
if (!content) return "";
|
||||
|
||||
const decoded = decodeHtmlEntities(content);
|
||||
const blockquoteMatch = decoded.match(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/i);
|
||||
const rawText = blockquoteMatch?.[1]?.trim() || "";
|
||||
if (!rawText) return "";
|
||||
|
||||
return cleanWikiPlainText(rawText);
|
||||
}
|
||||
|
||||
function cleanWikiPlainText(raw: string): string {
|
||||
return decodeHtmlEntities(raw)
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/\u00a0/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(raw: string): string {
|
||||
return raw
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/gi, "'");
|
||||
}
|
||||
|
||||
async function buildGeometrySelectionRows(
|
||||
entities: Entity[],
|
||||
geometriesByEntityId: Record<string, RelationGeometry[]>
|
||||
): Promise<GeometrySelectionRow[]> {
|
||||
return Promise.all(entities.map(async (entity) => {
|
||||
const geometries = geometriesByEntityId[entity.id] || [];
|
||||
const displayGeometries = await Promise.all(geometries.map(async (geometry) => {
|
||||
const center = geometry.draw_geometry ? getGeometryRepresentativePoint(geometry.draw_geometry) : null;
|
||||
if (!center) {
|
||||
return {
|
||||
id: geometry.id,
|
||||
center: null,
|
||||
adminLabel: null,
|
||||
adminAddress: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const place = await reverseGeocodePresentPlace(center[0], center[1]);
|
||||
return {
|
||||
id: geometry.id,
|
||||
center,
|
||||
adminLabel: place.label,
|
||||
adminAddress: place.address,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: geometry.id,
|
||||
center,
|
||||
adminLabel: null,
|
||||
adminAddress: null,
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
return {
|
||||
entity,
|
||||
geometries: displayGeometries,
|
||||
featureCollection: relationGeometriesToFeatureCollection(geometries),
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
function filterRelationGeometriesByEarliestStartTime(
|
||||
source: Record<string, RelationGeometry[]>
|
||||
): Record<string, RelationGeometry[]> {
|
||||
const result: Record<string, RelationGeometry[]> = {};
|
||||
|
||||
for (const [entityId, geometries] of Object.entries(source)) {
|
||||
const rows = (geometries || []).filter((geometry) => Boolean(geometry?.id && geometry.draw_geometry));
|
||||
if (!rows.length) {
|
||||
result[entityId] = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
const timedRows = rows.filter((geometry) => Number.isFinite(geometry.time_start));
|
||||
const candidateRows = timedRows.length ? timedRows : rows;
|
||||
const minStartTime = Math.min(...candidateRows.map((geometry) =>
|
||||
Number.isFinite(geometry.time_start) ? Number(geometry.time_start) : Number.POSITIVE_INFINITY
|
||||
));
|
||||
|
||||
result[entityId] = Number.isFinite(minStartTime)
|
||||
? candidateRows.filter((geometry) => Number(geometry.time_start) === minStartTime)
|
||||
: candidateRows;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function relationGeometriesToFeatureCollection(geometries: RelationGeometry[]): FeatureCollection {
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features: geometries
|
||||
.filter((geometry) => Boolean(geometry?.id && geometry.draw_geometry))
|
||||
.map((geometry) => ({
|
||||
type: "Feature" as const,
|
||||
properties: {
|
||||
id: geometry.id,
|
||||
type: geometry.type,
|
||||
time_start: geometry.time_start,
|
||||
time_end: geometry.time_end,
|
||||
bound_with: geometry.bound_with,
|
||||
},
|
||||
geometry: geometry.draw_geometry,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function getFeatureCollectionMinTimeStart(fc: FeatureCollection): number | null {
|
||||
const values = fc.features
|
||||
.map((feature) => feature.properties.time_start)
|
||||
.filter((value): value is number => Number.isFinite(value));
|
||||
if (!values.length) return null;
|
||||
return Math.min(...values);
|
||||
}
|
||||
|
||||
function getEntityPreferredTimeStart(entity: Entity | null, fallbackGeometries: FeatureCollection): number | null {
|
||||
if (Number.isFinite(entity?.time_start)) {
|
||||
return Number(entity?.time_start);
|
||||
}
|
||||
return getFeatureCollectionMinTimeStart(fallbackGeometries);
|
||||
}
|
||||
|
||||
function findRelationWikiBySlug(source: Record<string, Wiki>, slug: string): Wiki | undefined {
|
||||
const direct = source[slug];
|
||||
if (direct) return direct;
|
||||
|
||||
const target = normalizeWikiSlugForCompare(slug);
|
||||
if (!target) return undefined;
|
||||
return Object.entries(source).find(([key, wiki]) =>
|
||||
normalizeWikiSlugForCompare(key) === target ||
|
||||
normalizeWikiSlugForCompare(wiki.slug) === target
|
||||
)?.[1];
|
||||
}
|
||||
|
||||
function findRelationEntityIdsByWikiSlug(source: Record<string, string[]>, slug: string): string[] {
|
||||
const direct = source[slug];
|
||||
if (direct?.length) return direct;
|
||||
|
||||
const target = normalizeWikiSlugForCompare(slug);
|
||||
if (!target) return [];
|
||||
for (const [key, ids] of Object.entries(source)) {
|
||||
if (normalizeWikiSlugForCompare(key) === target) return ids;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeWikiSlugForCompare(value: string | null | undefined): string {
|
||||
let raw = String(value || "").trim();
|
||||
if (!raw) return "";
|
||||
try {
|
||||
raw = decodeURIComponent(raw);
|
||||
} catch {
|
||||
// Keep the original value if it is not valid percent-encoded text.
|
||||
}
|
||||
return raw
|
||||
.replace(/^\/+/, "")
|
||||
.replace(/^wiki\//i, "")
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLocaleLowerCase("vi-VN");
|
||||
}
|
||||
|
||||
function cloneStringArrayRecord(source: Record<string, string[]>): Record<string, string[]> {
|
||||
const result: Record<string, string[]> = {};
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
result[key] = [...value];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function appendUnique(target: Record<string, string[]>, key: string, value: string) {
|
||||
if (!target[key]) {
|
||||
target[key] = [value];
|
||||
return;
|
||||
}
|
||||
if (!target[key].includes(value)) target[key].push(value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import type { Entity } from "@/uhm/api/entities";
|
||||
import type { RelationGeometry } from "@/uhm/api/relations";
|
||||
import type { Wiki } from "@/uhm/api/wikis";
|
||||
import type { FeatureCollection } from "@/uhm/types/geo";
|
||||
import { getGeometryRepresentativePoint } from "@/uhm/components/map/mapUtils";
|
||||
import { reverseGeocodePresentPlace } from "@/uhm/api/goongPlaces";
|
||||
|
||||
export interface GeometrySelectionRow {
|
||||
entity: Entity;
|
||||
geometries: Array<{
|
||||
id: string;
|
||||
center: [number, number] | null;
|
||||
adminLabel: string | null;
|
||||
adminAddress: string | null;
|
||||
}>;
|
||||
featureCollection: FeatureCollection;
|
||||
}
|
||||
|
||||
export function cleanWikiPreviewQuote(raw: string | null | undefined): string {
|
||||
const decoded = decodeHtmlEntities(String(raw || ""));
|
||||
const blockquote = extractWikiBlockquoteText(decoded);
|
||||
return cleanWikiPlainText(blockquote || decoded);
|
||||
}
|
||||
|
||||
export function extractWikiBlockquoteText(content: string | null | undefined): string {
|
||||
if (!content) return "";
|
||||
|
||||
const decoded = decodeHtmlEntities(content);
|
||||
const blockquoteMatch = decoded.match(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/i);
|
||||
const rawText = blockquoteMatch?.[1]?.trim() || "";
|
||||
if (!rawText) return "";
|
||||
|
||||
return cleanWikiPlainText(rawText);
|
||||
}
|
||||
|
||||
export function cleanWikiPlainText(raw: string): string {
|
||||
return decodeHtmlEntities(raw)
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/\u00a0/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function decodeHtmlEntities(raw: string): string {
|
||||
return raw
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/gi, "'");
|
||||
}
|
||||
|
||||
export async function buildGeometrySelectionRows(
|
||||
entities: Entity[],
|
||||
geometriesByEntityId: Record<string, RelationGeometry[]>
|
||||
): Promise<GeometrySelectionRow[]> {
|
||||
return Promise.all(entities.map(async (entity) => {
|
||||
const geometries = geometriesByEntityId[entity.id] || [];
|
||||
const displayGeometries = await Promise.all(geometries.map(async (geometry) => {
|
||||
const center = geometry.draw_geometry ? getGeometryRepresentativePoint(geometry.draw_geometry) : null;
|
||||
if (!center) {
|
||||
return {
|
||||
id: geometry.id,
|
||||
center: null,
|
||||
adminLabel: null,
|
||||
adminAddress: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const place = await reverseGeocodePresentPlace(center[0], center[1]);
|
||||
return {
|
||||
id: geometry.id,
|
||||
center,
|
||||
adminLabel: place.label,
|
||||
adminAddress: place.address,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: geometry.id,
|
||||
center,
|
||||
adminLabel: null,
|
||||
adminAddress: null,
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
return {
|
||||
entity,
|
||||
geometries: displayGeometries,
|
||||
featureCollection: relationGeometriesToFeatureCollection(geometries),
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
export function filterRelationGeometriesByEarliestStartTime(
|
||||
source: Record<string, RelationGeometry[]>
|
||||
): Record<string, RelationGeometry[]> {
|
||||
const result: Record<string, RelationGeometry[]> = {};
|
||||
|
||||
for (const [entityId, geometries] of Object.entries(source)) {
|
||||
const rows = (geometries || []).filter((geometry) => Boolean(geometry?.id && geometry.draw_geometry));
|
||||
if (!rows.length) {
|
||||
result[entityId] = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
const timedRows = rows.filter((geometry) => Number.isFinite(geometry.time_start));
|
||||
const candidateRows = timedRows.length ? timedRows : rows;
|
||||
const minStartTime = Math.min(...candidateRows.map((geometry) =>
|
||||
Number.isFinite(geometry.time_start) ? Number(geometry.time_start) : Number.POSITIVE_INFINITY
|
||||
));
|
||||
|
||||
result[entityId] = Number.isFinite(minStartTime)
|
||||
? candidateRows.filter((geometry) => Number(geometry.time_start) === minStartTime)
|
||||
: candidateRows;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function relationGeometriesToFeatureCollection(geometries: RelationGeometry[]): FeatureCollection {
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features: geometries
|
||||
.filter((geometry) => Boolean(geometry?.id && geometry.draw_geometry))
|
||||
.map((geometry) => ({
|
||||
type: "Feature" as const,
|
||||
properties: {
|
||||
id: geometry.id,
|
||||
type: geometry.type,
|
||||
time_start: geometry.time_start,
|
||||
time_end: geometry.time_end,
|
||||
bound_with: geometry.bound_with,
|
||||
},
|
||||
geometry: geometry.draw_geometry,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function getFeatureCollectionMinTimeStart(fc: FeatureCollection): number | null {
|
||||
const values = fc.features
|
||||
.map((feature) => feature.properties.time_start)
|
||||
.filter((value): value is number => Number.isFinite(value));
|
||||
if (!values.length) return null;
|
||||
return Math.min(...values);
|
||||
}
|
||||
|
||||
export function getEntityPreferredTimeStart(entity: Entity | null, fallbackGeometries: FeatureCollection): number | null {
|
||||
if (Number.isFinite(entity?.time_start)) {
|
||||
return Number(entity?.time_start);
|
||||
}
|
||||
return getFeatureCollectionMinTimeStart(fallbackGeometries);
|
||||
}
|
||||
|
||||
export function findRelationWikiBySlug(source: Record<string, Wiki>, slug: string): Wiki | undefined {
|
||||
const direct = source[slug];
|
||||
if (direct) return direct;
|
||||
|
||||
const target = normalizeWikiSlugForCompare(slug);
|
||||
if (!target) return undefined;
|
||||
return Object.entries(source).find(([key, wiki]) =>
|
||||
normalizeWikiSlugForCompare(key) === target ||
|
||||
normalizeWikiSlugForCompare(wiki.slug) === target
|
||||
)?.[1];
|
||||
}
|
||||
|
||||
export function findRelationEntityIdsByWikiSlug(source: Record<string, string[]>, slug: string): string[] {
|
||||
const direct = source[slug];
|
||||
if (direct?.length) return direct;
|
||||
|
||||
const target = normalizeWikiSlugForCompare(slug);
|
||||
if (!target) return [];
|
||||
for (const [key, ids] of Object.entries(source)) {
|
||||
if (normalizeWikiSlugForCompare(key) === target) return ids;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function normalizeWikiSlugForCompare(value: string | null | undefined): string {
|
||||
let raw = String(value || "").trim();
|
||||
if (!raw) return "";
|
||||
try {
|
||||
raw = decodeURIComponent(raw);
|
||||
} catch {
|
||||
// Keep the original value if it is not valid percent-encoded text.
|
||||
}
|
||||
return raw
|
||||
.replace(/^\/+/, "")
|
||||
.replace(/^wiki\//i, "")
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLocaleLowerCase("vi-VN");
|
||||
}
|
||||
|
||||
export function cloneStringArrayRecord(source: Record<string, string[]>): Record<string, string[]> {
|
||||
const result: Record<string, string[]> = {};
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
result[key] = [...value];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function appendUnique(target: Record<string, string[]>, key: string, value: string) {
|
||||
if (!target[key]) {
|
||||
target[key] = [value];
|
||||
return;
|
||||
}
|
||||
if (!target[key].includes(value)) target[key].push(value);
|
||||
}
|
||||
Reference in New Issue
Block a user