feat: implement Goong place search integration and add geometry representative point utilities
This commit is contained in:
@@ -0,0 +1,645 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { searchGeometriesByEntityName, type EntityGeometriesSearchItem, type EntityGeometrySearchGeo } from "@/uhm/api/geometries";
|
||||
import {
|
||||
fetchPresentPlaceDetail,
|
||||
hasSearchMapApiKey,
|
||||
reverseGeocodePresentPlace,
|
||||
searchPresentPlaces,
|
||||
type PresentPlacePrediction,
|
||||
type PresentPlaceSelection,
|
||||
} from "@/uhm/api/goongPlaces";
|
||||
import { getGeometryRepresentativePoint } from "@/uhm/components/map/mapUtils";
|
||||
|
||||
export type { PresentPlaceSelection } from "@/uhm/api/goongPlaces";
|
||||
|
||||
export type HistoricalGeometryFocusPayload = {
|
||||
entity: EntityGeometriesSearchItem;
|
||||
geometry: EntityGeometrySearchGeo;
|
||||
representativePoint: [number, number] | null;
|
||||
adminLabel: string | null;
|
||||
};
|
||||
|
||||
type SearchMode = "present" | "history";
|
||||
|
||||
type AdminLabelState = {
|
||||
status: "loading" | "loaded" | "error";
|
||||
label: string | null;
|
||||
address: string | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
focusedPlace: PresentPlaceSelection | null;
|
||||
onFocusPlace: (place: PresentPlaceSelection) => void;
|
||||
onFocusHistoricalGeometry: (payload: HistoricalGeometryFocusPayload) => void;
|
||||
onClearFocus: () => void;
|
||||
rightOffset?: number;
|
||||
};
|
||||
|
||||
export default function PresentPlaceSearch({
|
||||
focusedPlace,
|
||||
onFocusPlace,
|
||||
onFocusHistoricalGeometry,
|
||||
onClearFocus,
|
||||
rightOffset = 18,
|
||||
}: Props) {
|
||||
const [mode, setMode] = useState<SearchMode>("present");
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<PresentPlacePrediction[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectingPlaceId, setSelectingPlaceId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [historicalQuery, setHistoricalQuery] = useState("");
|
||||
const [historicalResults, setHistoricalResults] = useState<EntityGeometriesSearchItem[]>([]);
|
||||
const [isHistoricalLoading, setIsHistoricalLoading] = useState(false);
|
||||
const [historicalError, setHistoricalError] = useState<string | null>(null);
|
||||
const [expandedEntityId, setExpandedEntityId] = useState<string | null>(null);
|
||||
const [selectingGeometryId, setSelectingGeometryId] = useState<string | null>(null);
|
||||
const [adminLabels, setAdminLabels] = useState<Record<string, AdminLabelState>>({});
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const requestSeqRef = useRef(0);
|
||||
const historicalRequestSeqRef = useRef(0);
|
||||
const hasApiKey = hasSearchMapApiKey();
|
||||
|
||||
const activeQuery = mode === "present" ? query : historicalQuery;
|
||||
const activeError = mode === "present" ? error : historicalError;
|
||||
const activeLoading = mode === "present" ? isLoading : isHistoricalLoading;
|
||||
|
||||
const expandedItem = useMemo(() => {
|
||||
if (!expandedEntityId) return null;
|
||||
return historicalResults.find((item) => item.entity_id === expandedEntityId) || null;
|
||||
}, [expandedEntityId, historicalResults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "present") return;
|
||||
|
||||
const keyword = query.trim();
|
||||
if (!keyword || keyword.length < 2) {
|
||||
setResults([]);
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasApiKey) {
|
||||
setResults([]);
|
||||
setIsLoading(false);
|
||||
setError("Thiếu SEARCH_MAP_API_KEY.");
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const seq = requestSeqRef.current + 1;
|
||||
requestSeqRef.current = seq;
|
||||
const timer = window.setTimeout(() => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
searchPresentPlaces(keyword, controller.signal)
|
||||
.then((nextResults) => {
|
||||
if (requestSeqRef.current !== seq) return;
|
||||
setResults(nextResults);
|
||||
setIsOpen(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (controller.signal.aborted || requestSeqRef.current !== seq) return;
|
||||
setResults([]);
|
||||
setError(err instanceof Error ? err.message : "Không search được địa điểm.");
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestSeqRef.current === seq) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
}, 260);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [hasApiKey, mode, query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "history") return;
|
||||
|
||||
const keyword = historicalQuery.trim();
|
||||
if (!keyword || keyword.length < 2) {
|
||||
setHistoricalResults([]);
|
||||
setIsHistoricalLoading(false);
|
||||
setHistoricalError(null);
|
||||
setExpandedEntityId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const seq = historicalRequestSeqRef.current + 1;
|
||||
historicalRequestSeqRef.current = seq;
|
||||
const timer = window.setTimeout(() => {
|
||||
setIsHistoricalLoading(true);
|
||||
setHistoricalError(null);
|
||||
searchGeometriesByEntityName(keyword, { limit: 12 })
|
||||
.then((response) => {
|
||||
if (historicalRequestSeqRef.current !== seq) return;
|
||||
setHistoricalResults(response.items || []);
|
||||
setExpandedEntityId(null);
|
||||
setIsOpen(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (historicalRequestSeqRef.current !== seq) return;
|
||||
setHistoricalResults([]);
|
||||
setHistoricalError(err instanceof Error ? err.message : "Không search được entity lịch sử.");
|
||||
})
|
||||
.finally(() => {
|
||||
if (historicalRequestSeqRef.current === seq) {
|
||||
setIsHistoricalLoading(false);
|
||||
}
|
||||
});
|
||||
}, 260);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [historicalQuery, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "history" || !expandedItem || expandedItem.geometries.length <= 1 || !hasApiKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
for (const geometry of expandedItem.geometries) {
|
||||
const point = getGeometryRepresentativePoint(geometry.draw_geometry);
|
||||
if (!point) {
|
||||
setAdminLabels((prev) => ({
|
||||
...prev,
|
||||
[geometry.id]: { status: "error", label: null, address: null },
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
setAdminLabels((prev) => {
|
||||
if (prev[geometry.id]) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[geometry.id]: { status: "loading", label: null, address: null },
|
||||
};
|
||||
});
|
||||
|
||||
reverseGeocodePresentPlace(point[0], point[1], controller.signal)
|
||||
.then((place) => {
|
||||
setAdminLabels((prev) => ({
|
||||
...prev,
|
||||
[geometry.id]: {
|
||||
status: "loaded",
|
||||
label: place.label,
|
||||
address: place.address,
|
||||
},
|
||||
}));
|
||||
})
|
||||
.catch((err) => {
|
||||
if (controller.signal.aborted) return;
|
||||
console.warn("Reverse geocode historical geometry failed", err);
|
||||
setAdminLabels((prev) => ({
|
||||
...prev,
|
||||
[geometry.id]: { status: "error", label: null, address: null },
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
return () => controller.abort();
|
||||
}, [expandedItem, hasApiKey, mode]);
|
||||
|
||||
const selectPrediction = async (prediction: PresentPlacePrediction) => {
|
||||
setSelectingPlaceId(prediction.placeId);
|
||||
setError(null);
|
||||
try {
|
||||
const place = await fetchPresentPlaceDetail(prediction.placeId);
|
||||
onFocusPlace(place);
|
||||
setQuery(place.name || prediction.description);
|
||||
setResults([]);
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Không lấy được tọa độ địa điểm.");
|
||||
} finally {
|
||||
setSelectingPlaceId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const selectHistoricalEntity = (item: EntityGeometriesSearchItem) => {
|
||||
if (item.geometries.length === 1) {
|
||||
selectHistoricalGeometry(item, item.geometries[0]);
|
||||
return;
|
||||
}
|
||||
if (item.geometries.length > 1) {
|
||||
setExpandedEntityId((prev) => prev === item.entity_id ? null : item.entity_id);
|
||||
}
|
||||
};
|
||||
|
||||
const selectHistoricalGeometry = (
|
||||
item: EntityGeometriesSearchItem,
|
||||
geometry: EntityGeometrySearchGeo
|
||||
) => {
|
||||
setSelectingGeometryId(geometry.id);
|
||||
const labelState = adminLabels[geometry.id] || null;
|
||||
onFocusHistoricalGeometry({
|
||||
entity: item,
|
||||
geometry,
|
||||
representativePoint: getGeometryRepresentativePoint(geometry.draw_geometry),
|
||||
adminLabel: labelState?.label || null,
|
||||
});
|
||||
setHistoricalQuery(item.name);
|
||||
setHistoricalResults([]);
|
||||
setExpandedEntityId(null);
|
||||
setIsOpen(false);
|
||||
setSelectingGeometryId(null);
|
||||
};
|
||||
|
||||
const clearSearch = () => {
|
||||
if (mode === "present") {
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
setError(null);
|
||||
} else {
|
||||
setHistoricalQuery("");
|
||||
setHistoricalResults([]);
|
||||
setHistoricalError(null);
|
||||
setExpandedEntityId(null);
|
||||
}
|
||||
setIsOpen(false);
|
||||
onClearFocus();
|
||||
};
|
||||
|
||||
const switchMode = (nextMode: SearchMode) => {
|
||||
setMode(nextMode);
|
||||
setIsOpen(true);
|
||||
setError(null);
|
||||
setHistoricalError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
right: rightOffset,
|
||||
zIndex: 18,
|
||||
width: "min(392px, calc(100vw - 36px))",
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div style={searchCardStyle}>
|
||||
<div style={searchInputRowStyle}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchMode(mode === "present" ? "history" : "present")}
|
||||
title={mode === "present" ? "Switch to history search" : "Switch to present search"}
|
||||
aria-label={mode === "present" ? "Switch to history search" : "Switch to present search"}
|
||||
style={modeSwitchStyle}
|
||||
>
|
||||
{mode === "present" ? "Present" : "History"}
|
||||
</button>
|
||||
<input
|
||||
value={activeQuery}
|
||||
onChange={(event) => {
|
||||
if (mode === "present") {
|
||||
setQuery(event.target.value);
|
||||
} else {
|
||||
setHistoricalQuery(event.target.value);
|
||||
}
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
if (mode === "present" && results[0]) {
|
||||
event.preventDefault();
|
||||
void selectPrediction(results[0]);
|
||||
}
|
||||
if (mode === "history" && historicalResults[0]) {
|
||||
event.preventDefault();
|
||||
selectHistoricalEntity(historicalResults[0]);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={mode === "present" && !hasApiKey}
|
||||
placeholder={mode === "present" ? "Tìm địa điểm hiện tại" : "Tìm entity lịch sử"}
|
||||
style={inputStyle}
|
||||
/>
|
||||
{activeQuery || focusedPlace ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSearch}
|
||||
title="Clear"
|
||||
aria-label="Clear place search"
|
||||
style={clearButtonStyle}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOpen && shouldRenderResults(mode, activeQuery, activeLoading, activeError, results, historicalResults) ? (
|
||||
<div style={resultsPanelStyle}>
|
||||
{mode === "present" ? (
|
||||
<PresentResults
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
query={query}
|
||||
results={results}
|
||||
selectingPlaceId={selectingPlaceId}
|
||||
onSelect={selectPrediction}
|
||||
/>
|
||||
) : (
|
||||
<HistoricalResults
|
||||
isLoading={isHistoricalLoading}
|
||||
error={historicalError}
|
||||
query={historicalQuery}
|
||||
results={historicalResults}
|
||||
expandedEntityId={expandedEntityId}
|
||||
adminLabels={adminLabels}
|
||||
selectingGeometryId={selectingGeometryId}
|
||||
hasApiKey={hasApiKey}
|
||||
onSelectEntity={selectHistoricalEntity}
|
||||
onSelectGeometry={selectHistoricalGeometry}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PresentResults({
|
||||
isLoading,
|
||||
error,
|
||||
query,
|
||||
results,
|
||||
selectingPlaceId,
|
||||
onSelect,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
query: string;
|
||||
results: PresentPlacePrediction[];
|
||||
selectingPlaceId: string | null;
|
||||
onSelect: (prediction: PresentPlacePrediction) => Promise<void>;
|
||||
}) {
|
||||
if (isLoading) return <div style={statusStyle}>Đang tìm...</div>;
|
||||
if (error) return <div style={{ ...statusStyle, color: "#fecaca" }}>{error}</div>;
|
||||
if (!results.length && query.trim().length >= 2) return <div style={statusStyle}>Không có kết quả.</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
{results.map((result) => (
|
||||
<button
|
||||
key={result.placeId}
|
||||
type="button"
|
||||
onClick={() => void onSelect(result)}
|
||||
disabled={selectingPlaceId === result.placeId}
|
||||
style={{
|
||||
...resultButtonStyle,
|
||||
cursor: selectingPlaceId === result.placeId ? "wait" : "pointer",
|
||||
}}
|
||||
onMouseEnter={(event) => {
|
||||
event.currentTarget.style.background = "rgba(56, 189, 248, 0.1)";
|
||||
}}
|
||||
onMouseLeave={(event) => {
|
||||
event.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<span style={primaryResultTextStyle}>{result.mainText}</span>
|
||||
<span style={secondaryResultTextStyle}>{result.secondaryText || result.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoricalResults({
|
||||
isLoading,
|
||||
error,
|
||||
query,
|
||||
results,
|
||||
expandedEntityId,
|
||||
adminLabels,
|
||||
selectingGeometryId,
|
||||
hasApiKey,
|
||||
onSelectEntity,
|
||||
onSelectGeometry,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
query: string;
|
||||
results: EntityGeometriesSearchItem[];
|
||||
expandedEntityId: string | null;
|
||||
adminLabels: Record<string, AdminLabelState>;
|
||||
selectingGeometryId: string | null;
|
||||
hasApiKey: boolean;
|
||||
onSelectEntity: (item: EntityGeometriesSearchItem) => void;
|
||||
onSelectGeometry: (item: EntityGeometriesSearchItem, geometry: EntityGeometrySearchGeo) => void;
|
||||
}) {
|
||||
if (isLoading) return <div style={statusStyle}>Đang tìm entity...</div>;
|
||||
if (error) return <div style={{ ...statusStyle, color: "#fecaca" }}>{error}</div>;
|
||||
if (!results.length && query.trim().length >= 2) return <div style={statusStyle}>Không có entity phù hợp.</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
{results.map((item) => {
|
||||
const isExpanded = expandedEntityId === item.entity_id;
|
||||
return (
|
||||
<div key={item.entity_id} style={{ borderBottom: "1px solid rgba(148, 163, 184, 0.12)" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectEntity(item)}
|
||||
disabled={!item.geometries.length}
|
||||
style={{
|
||||
...resultButtonStyle,
|
||||
cursor: item.geometries.length ? "pointer" : "not-allowed",
|
||||
}}
|
||||
onMouseEnter={(event) => {
|
||||
if (item.geometries.length) event.currentTarget.style.background = "rgba(56, 189, 248, 0.1)";
|
||||
}}
|
||||
onMouseLeave={(event) => {
|
||||
event.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<span style={primaryResultTextStyle}>{item.name || item.entity_id}</span>
|
||||
<span style={secondaryResultTextStyle}>
|
||||
{item.geometries.length
|
||||
? `${item.geometries.length} geometry${item.geometries.length > 1 ? "s" : ""}`
|
||||
: "Không có geometry"}
|
||||
{item.description ? ` · ${item.description}` : ""}
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded ? (
|
||||
<div style={{ padding: "0 8px 8px", display: "grid", gap: 6 }}>
|
||||
{!hasApiKey ? (
|
||||
<div style={{ ...statusStyle, padding: "7px 8px" }}>
|
||||
Thiếu SEARCH_MAP_API_KEY để lấy địa danh hiện tại.
|
||||
</div>
|
||||
) : null}
|
||||
{item.geometries.map((geometry) => (
|
||||
<button
|
||||
key={geometry.id}
|
||||
type="button"
|
||||
onClick={() => onSelectGeometry(item, geometry)}
|
||||
disabled={selectingGeometryId === geometry.id}
|
||||
style={{
|
||||
border: "1px solid rgba(148, 163, 184, 0.16)",
|
||||
borderRadius: 8,
|
||||
background: "rgba(15, 23, 42, 0.68)",
|
||||
color: "#e2e8f0",
|
||||
padding: "8px 9px",
|
||||
textAlign: "left",
|
||||
cursor: selectingGeometryId === geometry.id ? "wait" : "pointer",
|
||||
}}
|
||||
>
|
||||
<span style={{ ...primaryResultTextStyle, fontSize: 12 }}>
|
||||
{formatAdminLabel(adminLabels[geometry.id])}
|
||||
</span>
|
||||
<span style={{ ...secondaryResultTextStyle, marginTop: 3 }}>
|
||||
{formatGeometryMeta(geometry)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRenderResults(
|
||||
mode: SearchMode,
|
||||
query: string,
|
||||
isLoading: boolean,
|
||||
error: string | null,
|
||||
presentResults: PresentPlacePrediction[],
|
||||
historicalResults: EntityGeometriesSearchItem[]
|
||||
): boolean {
|
||||
if (isLoading || error || query.trim().length >= 2) return true;
|
||||
return mode === "present" ? presentResults.length > 0 : historicalResults.length > 0;
|
||||
}
|
||||
|
||||
function formatAdminLabel(state: AdminLabelState | undefined): string {
|
||||
if (!state || state.status === "loading") return "Đang lấy địa danh hiện tại...";
|
||||
if (state.status === "error") return "Không lấy được địa danh hiện tại";
|
||||
return state.label || state.address || "Địa danh hiện tại không rõ";
|
||||
}
|
||||
|
||||
function formatGeometryMeta(geometry: EntityGeometrySearchGeo): string {
|
||||
const type = geometry.type || "geometry";
|
||||
const timeStart = geometry.time_start ?? null;
|
||||
const timeEnd = geometry.time_end ?? null;
|
||||
const time =
|
||||
timeStart !== null && timeEnd !== null
|
||||
? `${timeStart} - ${timeEnd}`
|
||||
: timeStart !== null
|
||||
? `từ ${timeStart}`
|
||||
: timeEnd !== null
|
||||
? `đến ${timeEnd}`
|
||||
: "không rõ thời gian";
|
||||
return `${type} · ${time}`;
|
||||
}
|
||||
|
||||
const searchCardStyle = {
|
||||
border: "1px solid rgba(148, 163, 184, 0.28)",
|
||||
borderRadius: 10,
|
||||
background: "rgba(15, 23, 42, 0.92)",
|
||||
boxShadow: "0 16px 36px rgba(2, 6, 23, 0.35)",
|
||||
color: "#e2e8f0",
|
||||
overflow: "hidden",
|
||||
backdropFilter: "blur(6px)",
|
||||
} satisfies CSSProperties;
|
||||
|
||||
const searchInputRowStyle = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "9px 10px",
|
||||
} satisfies CSSProperties;
|
||||
|
||||
const inputStyle = {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
border: "none",
|
||||
outline: "none",
|
||||
background: "transparent",
|
||||
color: "#f8fafc",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
} satisfies CSSProperties;
|
||||
|
||||
const modeSwitchStyle = {
|
||||
border: "none",
|
||||
borderRight: "1px solid rgba(148, 163, 184, 0.22)",
|
||||
background: "transparent",
|
||||
color: "#38bdf8",
|
||||
padding: "0 10px 0 0",
|
||||
fontSize: 11,
|
||||
fontWeight: 900,
|
||||
cursor: "pointer",
|
||||
lineHeight: 1,
|
||||
} satisfies CSSProperties;
|
||||
|
||||
const clearButtonStyle = {
|
||||
width: 26,
|
||||
height: 26,
|
||||
border: "1px solid rgba(148, 163, 184, 0.28)",
|
||||
borderRadius: 6,
|
||||
background: "rgba(15, 23, 42, 0.74)",
|
||||
color: "#cbd5e1",
|
||||
cursor: "pointer",
|
||||
fontWeight: 900,
|
||||
} satisfies CSSProperties;
|
||||
|
||||
const resultsPanelStyle = {
|
||||
marginTop: 8,
|
||||
overflow: "hidden",
|
||||
border: "1px solid rgba(148, 163, 184, 0.24)",
|
||||
borderRadius: 10,
|
||||
background: "rgba(15, 23, 42, 0.96)",
|
||||
boxShadow: "0 16px 36px rgba(2, 6, 23, 0.4)",
|
||||
color: "#e2e8f0",
|
||||
backdropFilter: "blur(6px)",
|
||||
} satisfies CSSProperties;
|
||||
|
||||
const resultButtonStyle = {
|
||||
display: "grid",
|
||||
gap: 3,
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: "#e2e8f0",
|
||||
textAlign: "left",
|
||||
} satisfies CSSProperties;
|
||||
|
||||
const primaryResultTextStyle = {
|
||||
display: "block",
|
||||
fontSize: 13,
|
||||
fontWeight: 900,
|
||||
overflowWrap: "anywhere",
|
||||
} satisfies CSSProperties;
|
||||
|
||||
const secondaryResultTextStyle = {
|
||||
display: "block",
|
||||
fontSize: 11,
|
||||
color: "#94a3b8",
|
||||
lineHeight: 1.35,
|
||||
overflowWrap: "anywhere",
|
||||
} satisfies CSSProperties;
|
||||
|
||||
const statusStyle = {
|
||||
padding: "11px 12px",
|
||||
color: "#94a3b8",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
} satisfies CSSProperties;
|
||||
@@ -419,6 +419,39 @@ export function collectCoordinatePairs(value: unknown): Array<[number, number]>
|
||||
return value.flatMap((item) => collectCoordinatePairs(item));
|
||||
}
|
||||
|
||||
export function getGeometryRepresentativePoint(geometry: Geometry): Coordinate | null {
|
||||
if (geometry.type === "Point") {
|
||||
return normalizeCoordinate(geometry.coordinates);
|
||||
}
|
||||
|
||||
if (geometry.type === "MultiPoint") {
|
||||
return getAverageCoordinate(geometry.coordinates);
|
||||
}
|
||||
|
||||
if (geometry.type === "LineString") {
|
||||
return getLineMidpointCoordinate(geometry.coordinates);
|
||||
}
|
||||
|
||||
if (geometry.type === "MultiLineString") {
|
||||
let bestLine: Coordinate[] | null = null;
|
||||
let bestLength = -1;
|
||||
for (const line of geometry.coordinates) {
|
||||
const length = getLineLength(line);
|
||||
if (length > bestLength) {
|
||||
bestLength = length;
|
||||
bestLine = line;
|
||||
}
|
||||
}
|
||||
return bestLine ? getLineMidpointCoordinate(bestLine) : null;
|
||||
}
|
||||
|
||||
if (geometry.type === "Polygon" || geometry.type === "MultiPolygon") {
|
||||
return getPolygonLabelPoint(geometry);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildPathArrowFeatureCollection(fc: FeatureCollection): FeatureCollection {
|
||||
const features: Feature[] = [];
|
||||
|
||||
@@ -877,6 +910,76 @@ function isLineGeometry(geometry: Geometry): boolean {
|
||||
return geometry.type === "LineString" || geometry.type === "MultiLineString";
|
||||
}
|
||||
|
||||
function normalizeCoordinate(value: unknown): Coordinate | null {
|
||||
if (!Array.isArray(value) || value.length < 2) return null;
|
||||
const lng = Number(value[0]);
|
||||
const lat = Number(value[1]);
|
||||
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null;
|
||||
return [lng, lat];
|
||||
}
|
||||
|
||||
function getAverageCoordinate(coordinates: Coordinate[]): Coordinate | null {
|
||||
const valid = coordinates
|
||||
.map((coordinate) => normalizeCoordinate(coordinate))
|
||||
.filter((coordinate): coordinate is Coordinate => Boolean(coordinate));
|
||||
if (!valid.length) return null;
|
||||
|
||||
const sum = valid.reduce(
|
||||
(acc, coordinate) => ({
|
||||
lng: acc.lng + coordinate[0],
|
||||
lat: acc.lat + coordinate[1],
|
||||
}),
|
||||
{ lng: 0, lat: 0 }
|
||||
);
|
||||
return [sum.lng / valid.length, sum.lat / valid.length];
|
||||
}
|
||||
|
||||
function getLineMidpointCoordinate(coordinates: Coordinate[]): Coordinate | null {
|
||||
const valid = coordinates
|
||||
.map((coordinate) => normalizeCoordinate(coordinate))
|
||||
.filter((coordinate): coordinate is Coordinate => Boolean(coordinate));
|
||||
if (!valid.length) return null;
|
||||
if (valid.length === 1) return valid[0];
|
||||
|
||||
const totalLength = getLineLength(valid);
|
||||
if (totalLength <= 0) return valid[Math.floor(valid.length / 2)];
|
||||
|
||||
const halfway = totalLength / 2;
|
||||
let travelled = 0;
|
||||
for (let i = 1; i < valid.length; i += 1) {
|
||||
const prev = valid[i - 1];
|
||||
const next = valid[i];
|
||||
const segmentLength = getCoordinateDistance(prev, next);
|
||||
if (travelled + segmentLength >= halfway) {
|
||||
const ratio = segmentLength > 0 ? (halfway - travelled) / segmentLength : 0;
|
||||
return [
|
||||
prev[0] + (next[0] - prev[0]) * ratio,
|
||||
prev[1] + (next[1] - prev[1]) * ratio,
|
||||
];
|
||||
}
|
||||
travelled += segmentLength;
|
||||
}
|
||||
|
||||
return valid[valid.length - 1];
|
||||
}
|
||||
|
||||
function getLineLength(coordinates: Coordinate[]): number {
|
||||
let total = 0;
|
||||
for (let i = 1; i < coordinates.length; i += 1) {
|
||||
const prev = normalizeCoordinate(coordinates[i - 1]);
|
||||
const next = normalizeCoordinate(coordinates[i]);
|
||||
if (!prev || !next) continue;
|
||||
total += getCoordinateDistance(prev, next);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function getCoordinateDistance(a: Coordinate, b: Coordinate): number {
|
||||
const dx = b[0] - a[0];
|
||||
const dy = b[1] - a[1];
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
function getLineCoordinateGroups(geometry: Geometry): Coordinate[][] {
|
||||
if (geometry.type === "LineString") return [geometry.coordinates];
|
||||
if (geometry.type === "MultiLineString") return geometry.coordinates;
|
||||
|
||||
Reference in New Issue
Block a user