refactor: reoute / splited

This commit is contained in:
taDuc
2026-06-16 14:12:11 +07:00
parent 4f9f2cd854
commit b821af9747
4 changed files with 647 additions and 674 deletions
+2 -36
View File
@@ -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(/&nbsp;/gi, " ")
.replace(/&#160;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/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 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(/&nbsp;/gi, " ")
.replace(/&#160;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/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);
}
+214
View File
@@ -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(/&nbsp;/gi, " ")
.replace(/&#160;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/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);
}