From 57a7843d806aeebfbe04cb3d1c5bdd14b1b4b5a2 Mon Sep 17 00:00:00 2001 From: taDuc Date: Sun, 19 Apr 2026 00:59:06 +0700 Subject: [PATCH] section review page --- app/submited/page.tsx | 1103 ++++++++++++++++++++++++++++++++++++++++ components/Map.tsx | 180 ++----- lib/selectingEngine.ts | 19 +- 3 files changed, 1156 insertions(+), 146 deletions(-) create mode 100644 app/submited/page.tsx diff --git a/app/submited/page.tsx b/app/submited/page.tsx new file mode 100644 index 0000000..916e420 --- /dev/null +++ b/app/submited/page.tsx @@ -0,0 +1,1103 @@ +"use client"; + +import { type CSSProperties, useEffect, useMemo, useState } from "react"; +import Map from "@/components/Map"; +import { ApiError } from "@/api/http"; +import { + approveSubmission, + fetchSections, + fetchSectionSubmissions, + rejectSubmission, + Section, + SectionSubmission, +} from "@/api/sections"; +import { DEFAULT_BACKGROUND_LAYER_VISIBILITY } from "@/lib/backgroundLayers"; +import { Feature, FeatureCollection, Geometry } from "@/lib/useEditorState"; + +type SubmissionStatusFilter = "pending" | "all"; +type ReviewAction = "approve" | "reject"; + +type SubmissionRow = { + section: Section; + submission: SectionSubmission; +}; + +type SnapshotSummary = { + featureCount: number; + entityCount: number; + geometryCount: number; + linkScopeCount: number; + entityOperations: Record; + geometryOperations: Record; + linkOperations: Record; +}; + +const DEFAULT_REVIEWER_ID = "admin"; +const EMPTY_FEATURE_COLLECTION: FeatureCollection = { + type: "FeatureCollection", + features: [], +}; + +const STATUS_LABELS: Record = { + pending: "Chờ duyệt", + approved: "Đã duyệt", + rejected: "Từ chối", + conflicted: "Conflict", +}; + +export default function SubmittedPage() { + const [rows, setRows] = useState([]); + const [selectedSubmissionId, setSelectedSubmissionId] = useState(null); + const [statusFilter, setStatusFilter] = useState("pending"); + const [reviewerId, setReviewerId] = useState(DEFAULT_REVIEWER_ID); + const [reviewNote, setReviewNote] = useState(""); + const [isLoading, setIsLoading] = useState(true); + const [reviewingSubmissionId, setReviewingSubmissionId] = useState(null); + const [selectedPreviewFeatureId, setSelectedPreviewFeatureId] = useState(null); + const [statusMessage, setStatusMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + + const loadSubmissions = async () => { + setIsLoading(true); + setErrorMessage(null); + setStatusMessage(null); + + try { + const sections = await fetchSections(); + const submissionGroups = await Promise.all( + sections.map(async (section) => { + const submissions = await fetchSectionSubmissions(section.id, { + includeSnapshot: true, + }); + return submissions.map((submission) => ({ + section, + submission, + })); + }) + ); + const nextRows = submissionGroups + .flat() + .sort((a, b) => + Date.parse(b.submission.submitted_at) - Date.parse(a.submission.submitted_at) + ); + + setRows(nextRows); + setSelectedSubmissionId((current) => { + if (current && nextRows.some((row) => row.submission.id === current)) { + return current; + } + return nextRows.find((row) => row.submission.status === "pending")?.submission.id + || nextRows[0]?.submission.id + || null; + }); + } catch (err) { + setErrorMessage(getErrorMessage(err, "Không tải được danh sách section đã submit.")); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadSubmissions(); + }, []); + + const visibleRows = useMemo(() => { + if (statusFilter === "all") return rows; + return rows.filter((row) => row.submission.status === "pending"); + }, [rows, statusFilter]); + + const selectedRow = useMemo(() => { + if (!selectedSubmissionId) return visibleRows[0] || null; + return ( + visibleRows.find((row) => row.submission.id === selectedSubmissionId) || + rows.find((row) => row.submission.id === selectedSubmissionId) || + visibleRows[0] || + null + ); + }, [rows, selectedSubmissionId, visibleRows]); + + const pendingCount = rows.filter((row) => row.submission.status === "pending").length; + const reviewedCount = rows.length - pendingCount; + const selectedSummary = selectedRow + ? summarizeSnapshot(selectedRow.submission.snapshot) + : null; + const selectedFeatureCollection = selectedRow + ? extractSnapshotFeatureCollection(selectedRow.submission.snapshot) + : EMPTY_FEATURE_COLLECTION; + const selectedPreviewFeature = selectedFeatureCollection.features.find((feature) => + String(feature.properties.id) === String(selectedPreviewFeatureId) + ) || null; + + useEffect(() => { + setSelectedPreviewFeatureId(null); + }, [selectedRow?.submission.id]); + + const handleReview = async (action: ReviewAction) => { + if (!selectedRow) return; + const actor = reviewerId.trim(); + if (!actor) { + setErrorMessage("Reviewer là bắt buộc."); + return; + } + if (selectedRow.submission.status !== "pending") { + setErrorMessage("Submission này không còn ở trạng thái chờ duyệt."); + return; + } + + const label = action === "approve" ? "duyệt" : "từ chối"; + const confirmed = window.confirm(`Xác nhận ${label} section "${selectedRow.section.title}"?`); + if (!confirmed) return; + + setReviewingSubmissionId(selectedRow.submission.id); + setErrorMessage(null); + setStatusMessage(null); + + try { + const input = { + reviewed_by: actor, + review_note: reviewNote.trim() || null, + }; + const updatedSubmission = action === "approve" + ? await approveSubmission(selectedRow.submission.id, input) + : await rejectSubmission(selectedRow.submission.id, input); + + setRows((currentRows) => + currentRows.map((row) => + row.submission.id === updatedSubmission.id + ? { + ...row, + section: { + ...row.section, + state: { + ...row.section.state, + status: updatedSubmission.status === "approved" + ? "approved" + : updatedSubmission.status === "rejected" + ? "rejected" + : row.section.state.status, + }, + }, + submission: updatedSubmission, + } + : row + ) + ); + setReviewNote(""); + setStatusMessage( + action === "approve" + ? "Đã duyệt submission và apply vào published data." + : "Đã từ chối submission." + ); + } catch (err) { + setErrorMessage(getErrorMessage(err, `Không thể ${label} submission.`)); + } finally { + setReviewingSubmissionId(null); + } + }; + + const isReviewingSelected = reviewingSubmissionId === selectedRow?.submission.id; + const canReviewSelected = Boolean( + selectedRow && + selectedRow.submission.status === "pending" && + !isReviewingSelected + ); + + return ( +
+
+
+

Review sections

+

Submitted sections

+

+ Xem các section đã submit, kiểm tra snapshot và duyệt dữ liệu vào published map. +

+
+
+ Mở editor + +
+
+ +
+ + + +
+ + {statusMessage ?
{statusMessage}
: null} + {errorMessage ?
{errorMessage}
: null} + +
+ + + +
+ +
+
+
+

Sections

+ {visibleRows.length} item +
+ + {isLoading ? ( +
Đang tải submissions...
+ ) : visibleRows.length === 0 ? ( +
+ Không có submission nào trong bộ lọc hiện tại. +
+ ) : ( +
+ {visibleRows.map((row) => { + const selected = selectedRow?.submission.id === row.submission.id; + return ( + + ); + })} +
+ )} +
+ +
+ {!selectedRow ? ( +
Chọn một section để xem chi tiết.
+ ) : ( + <> +
+
+

{selectedRow.section.title}

+

+ Section status: {selectedRow.section.state.status} · Version {selectedRow.section.state.version} +

+
+ +
+ +
+ + + + + + +
+ + {selectedRow.submission.review_note ? ( +
+ Review note +

{selectedRow.submission.review_note}

+
+ ) : null} + + {selectedSummary ? ( + <> +

Map preview

+ {selectedFeatureCollection.features.length > 0 ? ( +
+
+ +
+
+ Selected geometry + {selectedPreviewFeature ? ( +
+ + + + + + +
+ ) : ( +

+ Chọn một geometry trên preview để xem metadata. +

+ )} +
+
+ ) : ( +
+ Snapshot này không có editor_feature_collection để render bản đồ. +
+ )} + +

Snapshot

+
+ + + + +
+
+ + + +
+ + ) : ( +
Submission này không có snapshot để hiển thị.
+ )} + +
+ + +
+ + )} +
+
+
+ ); +} + +function Metric({ label, value }: { label: string; value: number }) { + return ( +
+ {value} + {label} +
+ ); +} + +function Info({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function StatusBadge({ status }: { status: SectionSubmission["status"] }) { + return ( + + {STATUS_LABELS[status] || status} + + ); +} + +function OperationBlock({ + title, + operations, +}: { + title: string; + operations: Record; +}) { + const entries = Object.entries(operations); + return ( +
+ {title} + {entries.length === 0 ? ( +

Không có operation.

+ ) : ( +
+ {entries.map(([operation, count]) => ( + + {operation}: {count} + + ))} +
+ )} +
+ ); +} + +function extractSnapshotFeatureCollection(rawSnapshot: unknown): FeatureCollection { + if (!rawSnapshot || typeof rawSnapshot !== "object" || Array.isArray(rawSnapshot)) { + return EMPTY_FEATURE_COLLECTION; + } + + const snapshot = rawSnapshot as Record; + const editorCollection = snapshot.editor_feature_collection; + if (isFeatureCollection(editorCollection)) { + return { + type: "FeatureCollection", + features: editorCollection.features.map(cloneFeature), + }; + } + + const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : []; + const features = geometries + .map((item): Feature | null => { + if (!item || typeof item !== "object" || Array.isArray(item)) return null; + const source = item as Record; + if (source.operation === "delete") return null; + + const id = source.id; + const geometry = source.draw_geometry || source.geometry; + if ((typeof id !== "string" && typeof id !== "number") || !isGeometry(geometry)) { + return null; + } + + return { + type: "Feature", + properties: { + id, + type: typeof source.type === "string" ? source.type : null, + geometry_preset: null, + entity_id: null, + entity_ids: [], + entity_name: null, + entity_type_id: null, + binding: normalizeStringArray(source.binding), + time_start: normalizeOptionalNumber(source.time_start), + time_end: normalizeOptionalNumber(source.time_end), + }, + geometry: cloneGeometry(geometry), + }; + }) + .filter((feature): feature is Feature => feature !== null); + + return { + type: "FeatureCollection", + features, + }; +} + +function summarizeSnapshot(rawSnapshot: unknown): SnapshotSummary | null { + if (!rawSnapshot || typeof rawSnapshot !== "object" || Array.isArray(rawSnapshot)) { + return null; + } + + const snapshot = rawSnapshot as Record; + const featureCollection = snapshot.editor_feature_collection as Record | undefined; + const features = Array.isArray(featureCollection?.features) + ? featureCollection.features + : []; + const entities = Array.isArray(snapshot.entities) ? snapshot.entities : []; + const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : []; + const linkScopes = Array.isArray(snapshot.link_scopes) ? snapshot.link_scopes : []; + + return { + featureCount: features.length, + entityCount: entities.length, + geometryCount: geometries.length, + linkScopeCount: linkScopes.length, + entityOperations: countOperations(entities), + geometryOperations: countOperations(geometries), + linkOperations: countOperations(linkScopes), + }; +} + +function isFeatureCollection(value: unknown): value is FeatureCollection { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const source = value as Record; + return source.type === "FeatureCollection" && + Array.isArray(source.features) && + source.features.every(isFeature); +} + +function isFeature(value: unknown): value is Feature { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const source = value as Record; + const properties = source.properties; + if (source.type !== "Feature" || !isGeometry(source.geometry)) return false; + if (!properties || typeof properties !== "object" || Array.isArray(properties)) return false; + const id = (properties as Record).id; + return typeof id === "string" || typeof id === "number"; +} + +function isGeometry(value: unknown): value is Geometry { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const source = value as Record; + return typeof source.type === "string" && Array.isArray(source.coordinates); +} + +function cloneFeature(feature: Feature): Feature { + return JSON.parse(JSON.stringify(feature)) as Feature; +} + +function cloneGeometry(geometry: Geometry): Geometry { + return JSON.parse(JSON.stringify(geometry)) as Geometry; +} + +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .map((item) => { + if (typeof item !== "string" && typeof item !== "number") return ""; + return String(item).trim(); + }) + .filter((item) => item.length > 0); +} + +function normalizeOptionalNumber(value: unknown): number | null { + if (value === null || value === undefined || value === "") return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function formatFeatureEntities(feature: Feature): string { + const named = normalizeStringArray(feature.properties.entity_names); + if (named.length > 0) return named.join(", "); + if (feature.properties.entity_name) return feature.properties.entity_name; + const ids = normalizeStringArray(feature.properties.entity_ids); + if (ids.length > 0) return ids.join(", "); + return feature.properties.entity_id || "Chưa gắn"; +} + +function formatFeatureTimeRange(feature: Feature): string { + const start = feature.properties.time_start; + const end = feature.properties.time_end; + if (start == null && end == null) return "Không giới hạn"; + if (start != null && end != null) return `${start} - ${end}`; + if (start != null) return `Từ ${start}`; + return `Đến ${end}`; +} + +function formatFeatureBinding(feature: Feature): string { + const binding = normalizeStringArray(feature.properties.binding); + return binding.length > 0 ? binding.join(", ") : "Không có"; +} + +function countOperations(items: unknown[]): Record { + return items.reduce>((acc, item) => { + const source = item && typeof item === "object" && !Array.isArray(item) + ? item as Record + : {}; + const operation = typeof source.operation === "string" && source.operation.trim() + ? source.operation.trim() + : "unknown"; + acc[operation] = (acc[operation] || 0) + 1; + return acc; + }, {}); +} + +function formatDateTime(value: string | null | undefined): string { + if (!value) return "Chưa có"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("vi-VN", { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} + +function getErrorMessage(err: unknown, fallback: string): string { + if (err instanceof ApiError) { + const parsed = parseErrorBody(err.body); + return parsed || err.body || fallback; + } + if (err instanceof Error) return err.message; + return fallback; +} + +function parseErrorBody(body: string): string | null { + try { + const parsed = JSON.parse(body) as unknown; + if (parsed && typeof parsed === "object" && "error" in parsed) { + const error = (parsed as { error?: unknown }).error; + return typeof error === "string" ? error : null; + } + } catch { + return null; + } + return null; +} + +function getStatusStyle(status: SectionSubmission["status"]): CSSProperties { + if (status === "approved") { + return { + background: "#d9f5df", + color: "#17652a", + borderColor: "#8bd19a", + }; + } + if (status === "rejected") { + return { + background: "#ffe2dd", + color: "#8a1f12", + borderColor: "#ef9a8d", + }; + } + if (status === "conflicted") { + return { + background: "#fff1c7", + color: "#755400", + borderColor: "#dfbf59", + }; + } + return { + background: "#e2f1ff", + color: "#0c4f85", + borderColor: "#8bbde4", + }; +} + +const styles: Record = { + page: { + minHeight: "100vh", + padding: "32px", + background: "#f7f8f3", + color: "#1f241f", + }, + header: { + display: "flex", + justifyContent: "space-between", + gap: "20px", + alignItems: "flex-start", + flexWrap: "wrap", + marginBottom: "22px", + }, + kicker: { + margin: "0 0 8px", + color: "#4f6f39", + fontSize: "13px", + fontWeight: 700, + textTransform: "uppercase", + }, + title: { + margin: 0, + fontSize: "36px", + lineHeight: 1.15, + letterSpacing: 0, + }, + subtitle: { + maxWidth: "680px", + margin: "10px 0 0", + color: "#596055", + lineHeight: 1.5, + }, + headerActions: { + display: "flex", + gap: "10px", + flexWrap: "wrap", + justifyContent: "flex-end", + }, + primaryButton: { + minHeight: "38px", + border: "1px solid #284f2f", + borderRadius: "8px", + padding: "8px 14px", + background: "#2f6d3a", + color: "#ffffff", + fontWeight: 700, + cursor: "pointer", + }, + secondaryLink: { + minHeight: "38px", + border: "1px solid #c7cebc", + borderRadius: "8px", + padding: "8px 14px", + background: "#ffffff", + color: "#2a3326", + fontWeight: 700, + textDecoration: "none", + }, + statsGrid: { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", + gap: "12px", + marginBottom: "16px", + }, + metric: { + border: "1px solid #d5dbc8", + borderRadius: "8px", + padding: "14px", + background: "#ffffff", + }, + metricValue: { + display: "block", + fontSize: "28px", + lineHeight: 1.1, + fontWeight: 800, + color: "#254b2b", + }, + metricLabel: { + display: "block", + marginTop: "6px", + color: "#5a6356", + fontSize: "13px", + }, + successBanner: { + border: "1px solid #9dcc9c", + borderRadius: "8px", + padding: "12px 14px", + marginBottom: "12px", + background: "#e6f6e5", + color: "#1d5b25", + }, + errorBanner: { + border: "1px solid #ef9a8d", + borderRadius: "8px", + padding: "12px 14px", + marginBottom: "12px", + background: "#ffe2dd", + color: "#8a1f12", + }, + toolbar: { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 220px), 1fr))", + gap: "12px", + marginBottom: "16px", + alignItems: "end", + }, + fieldLabel: { + display: "grid", + gap: "6px", + color: "#424a3f", + fontSize: "13px", + fontWeight: 700, + }, + fieldLabelWide: { + display: "grid", + gap: "6px", + color: "#424a3f", + fontSize: "13px", + fontWeight: 700, + }, + input: { + width: "100%", + minHeight: "38px", + border: "1px solid #c9d0bd", + borderRadius: "8px", + padding: "8px 10px", + background: "#ffffff", + color: "#202820", + outline: "none", + }, + contentGrid: { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 360px), 1fr))", + gap: "16px", + alignItems: "start", + }, + listPanel: { + border: "1px solid #d5dbc8", + borderRadius: "8px", + background: "#ffffff", + minHeight: "520px", + overflow: "hidden", + }, + detailPanel: { + border: "1px solid #d5dbc8", + borderRadius: "8px", + background: "#ffffff", + minHeight: "520px", + padding: "18px", + }, + panelHeader: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + padding: "14px", + borderBottom: "1px solid #e3e7da", + }, + panelTitle: { + margin: 0, + fontSize: "18px", + }, + mutedText: { + margin: 0, + color: "#66705f", + fontSize: "13px", + lineHeight: 1.45, + }, + emptyState: { + padding: "18px", + color: "#66705f", + lineHeight: 1.5, + }, + submissionList: { + display: "grid", + gap: "8px", + padding: "10px", + maxHeight: "calc(100vh - 310px)", + overflow: "auto", + }, + submissionItem: { + width: "100%", + display: "grid", + gap: "6px", + textAlign: "left", + border: "1px solid #e1e5d9", + borderRadius: "8px", + padding: "12px", + background: "#ffffff", + color: "#202820", + cursor: "pointer", + }, + submissionItemSelected: { + borderColor: "#71a66a", + background: "#f0f8ee", + }, + itemTopLine: { + display: "flex", + gap: "8px", + justifyContent: "space-between", + alignItems: "center", + }, + itemTitle: { + minWidth: 0, + overflowWrap: "anywhere", + }, + itemMeta: { + color: "#5f695a", + fontSize: "13px", + overflowWrap: "anywhere", + }, + statusBadge: { + flex: "0 0 auto", + display: "inline-flex", + alignItems: "center", + minHeight: "24px", + border: "1px solid", + borderRadius: "8px", + padding: "2px 8px", + fontSize: "12px", + fontWeight: 800, + whiteSpace: "nowrap", + }, + detailHeader: { + display: "flex", + justifyContent: "space-between", + gap: "16px", + alignItems: "flex-start", + marginBottom: "16px", + }, + detailTitle: { + margin: "0 0 6px", + fontSize: "24px", + lineHeight: 1.25, + overflowWrap: "anywhere", + }, + metaGrid: { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", + gap: "10px", + marginBottom: "16px", + }, + infoCell: { + border: "1px solid #e1e5d9", + borderRadius: "8px", + padding: "10px", + background: "#fbfcf8", + minWidth: 0, + }, + infoLabel: { + display: "block", + color: "#66705f", + fontSize: "12px", + fontWeight: 700, + marginBottom: "5px", + }, + infoValue: { + display: "block", + color: "#202820", + fontSize: "13px", + overflowWrap: "anywhere", + }, + noteBox: { + border: "1px solid #e1e5d9", + borderRadius: "8px", + padding: "12px", + marginBottom: "16px", + background: "#fbfcf8", + }, + noteText: { + margin: "6px 0 0", + color: "#40483c", + lineHeight: 1.5, + overflowWrap: "anywhere", + }, + sectionHeading: { + margin: "18px 0 10px", + fontSize: "18px", + }, + summaryGrid: { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))", + gap: "10px", + marginBottom: "12px", + }, + operationGrid: { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", + gap: "10px", + }, + operationBlock: { + border: "1px solid #e1e5d9", + borderRadius: "8px", + padding: "12px", + background: "#fbfcf8", + }, + operationRows: { + display: "flex", + flexWrap: "wrap", + gap: "8px", + marginTop: "10px", + }, + operationPill: { + border: "1px solid #cfd8c2", + borderRadius: "8px", + padding: "4px 8px", + background: "#ffffff", + color: "#394136", + fontSize: "13px", + }, + previewGrid: { + display: "grid", + gridTemplateColumns: "minmax(min(100%, 420px), 1fr) minmax(min(100%, 260px), 320px)", + gap: "12px", + marginBottom: "16px", + alignItems: "stretch", + }, + mapPreview: { + height: "420px", + minHeight: "320px", + border: "1px solid #d5dbc8", + borderRadius: "8px", + overflow: "hidden", + background: "#0b1220", + }, + featurePanel: { + border: "1px solid #e1e5d9", + borderRadius: "8px", + padding: "12px", + background: "#fbfcf8", + minWidth: 0, + }, + featureDetails: { + display: "grid", + gap: "8px", + marginTop: "10px", + }, + reviewActions: { + display: "flex", + justifyContent: "flex-end", + gap: "10px", + marginTop: "22px", + paddingTop: "16px", + borderTop: "1px solid #e3e7da", + }, + dangerButton: { + minHeight: "40px", + border: "1px solid #a93322", + borderRadius: "8px", + padding: "8px 16px", + background: "#b94330", + color: "#ffffff", + fontWeight: 800, + cursor: "pointer", + }, + approveButton: { + minHeight: "40px", + border: "1px solid #276131", + borderRadius: "8px", + padding: "8px 16px", + background: "#2f7a3b", + color: "#ffffff", + fontWeight: 800, + cursor: "pointer", + }, +}; diff --git a/components/Map.tsx b/components/Map.tsx index 4d12f24..afe99de 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useCallback, useState } from "react"; +import { type CSSProperties, useEffect, useRef, useCallback, useState } from "react"; import maplibregl from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; @@ -26,16 +26,11 @@ type MapProps = { onUpdateFeature?: (id: string | number, geometry: Geometry) => void; allowGeometryEditing?: boolean; respectBindingFilter?: boolean; -}; - -type PointIconSpec = { - id: string; - fill: string; - stroke: string; - label: string; + height?: CSSProperties["height"]; }; const DEFAULT_POINT_ICON_ID = "point-icon-default"; +const POINT_ICON_URL = "/point.png"; const PATH_ARROW_ICON_ID = "path-arrow-icon"; const MAP_MIN_ZOOM = 1; const MAP_MAX_ZOOM = 10; @@ -115,59 +110,6 @@ const PATH_RENDER_BY_TYPE: Record = { shipping_route: true, }; -const POINT_COLOR_BY_TYPE: Record = { - person_deathplace: "#dc2626", - person_birthplace: "#2563eb", - person_activity: "#14b8a6", - temple: "#7c3aed", - capital: "#f59e0b", - city: "#0f766e", - fortress: "#b91c1c", - castle: "#6d28d9", - ruin: "#57534e", - port: "#0c4a6e", - bridge: "#9a3412", -}; - -const POINT_ICON_SPECS: PointIconSpec[] = [ - { id: "point-icon-person-deathplace", fill: "#dc2626", stroke: "#7f1d1d", label: "D" }, - { id: "point-icon-person-birthplace", fill: "#2563eb", stroke: "#1e3a8a", label: "B" }, - { id: "point-icon-person-activity", fill: "#14b8a6", stroke: "#134e4a", label: "A" }, - { id: "point-icon-temple", fill: "#7c3aed", stroke: "#4c1d95", label: "T" }, - { id: "point-icon-capital", fill: "#f59e0b", stroke: "#92400e", label: "Ca" }, - { id: "point-icon-city", fill: "#0f766e", stroke: "#134e4a", label: "Ci" }, - { id: "point-icon-fortress", fill: "#b91c1c", stroke: "#7f1d1d", label: "F" }, - { id: "point-icon-castle", fill: "#6d28d9", stroke: "#4c1d95", label: "Cs" }, - { id: "point-icon-ruin", fill: "#57534e", stroke: "#292524", label: "R" }, - { id: "point-icon-port", fill: "#0c4a6e", stroke: "#082f49", label: "P" }, - { id: "point-icon-bridge", fill: "#9a3412", stroke: "#7c2d12", label: "Br" }, - - // Backward-compatible icon ids used by older naming values. - { id: "point-icon-country", fill: "#1d4ed8", stroke: "#1e3a8a", label: "C" }, - { id: "point-icon-kingdom", fill: "#ca8a04", stroke: "#854d0e", label: "K" }, - { id: "point-icon-region", fill: "#b91c1c", stroke: "#7f1d1d", label: "R" }, - { id: "point-icon-event", fill: "#be123c", stroke: "#881337", label: "E" }, - { id: DEFAULT_POINT_ICON_ID, fill: "#475569", stroke: "#1e293b", label: "P" }, -]; - -const POINT_ICON_BY_TYPE: Record = { - person_deathplace: "point-icon-person-deathplace", - person_birthplace: "point-icon-person-birthplace", - person_activity: "point-icon-person-activity", - temple: "point-icon-temple", - capital: "point-icon-capital", - city: "point-icon-city", - fortress: "point-icon-fortress", - country: "point-icon-country", - castle: "point-icon-castle", - kingdom: "point-icon-kingdom", - ruin: "point-icon-ruin", - port: "point-icon-port", - bridge: "point-icon-bridge", - region: "point-icon-region", - event: "point-icon-event", -}; - export default function Map({ mode, draft, @@ -179,6 +121,7 @@ export default function Map({ onUpdateFeature, allowGeometryEditing = true, respectBindingFilter = true, + height = "100vh", }: MapProps) { const mapRef = useRef(null); const modeRef = useRef(mode); @@ -703,16 +646,7 @@ export default function Map({ type: "circle", source: "places", paint: { - "circle-color": [ - "case", - [ - "==", - ["coalesce", ["get", "entity_id"], ""], - "", - ], - "#ef4444", - buildTypeMatchExpression(POINT_COLOR_BY_TYPE, "#10b981"), - ], + "circle-color": "#ef4444", "circle-radius": 4, "circle-stroke-color": "#ffffff", "circle-stroke-width": 1, @@ -720,21 +654,7 @@ export default function Map({ }, }); - // Add type-specific point icons (country/castle/kingdom/...) and render with symbol layer. - const hasTypeIcons = ensurePointIcons(map); - if (hasTypeIcons && !map.getLayer("places-symbol")) { - map.addLayer({ - id: "places-symbol", - type: "symbol", - source: "places", - layout: { - "icon-image": buildPointIconExpression(), - "icon-size": 0.5, - "icon-anchor": "center", - "icon-allow-overlap": true, - }, - }); - } + addPointSymbolLayer(map); // init drawing const cleanup = initDrawing( @@ -917,7 +837,7 @@ export default function Map({ }, [allowGeometryEditing, draft, selectedFeatureId, applyDraftToMap]); return ( -
+
{ + if (!hasPointIcon || !map.getSource("places") || map.getLayer("places-symbol")) return; + + map.addLayer({ + id: "places-symbol", + type: "symbol", + source: "places", + layout: { + "icon-image": DEFAULT_POINT_ICON_ID, + "icon-size": 0.06, + "icon-anchor": "center", + "icon-allow-overlap": true, + }, + }); + + if (map.getLayer("places-circle")) { + map.setLayoutProperty("places-circle", "visibility", "none"); } - - const imageData = createPointIconImageData(spec); - if (!imageData) continue; - map.addImage(spec.id, imageData, { pixelRatio: 2 }); - added = true; - } - return added; + }); } -function createPointIconImageData(spec: PointIconSpec): ImageData | null { - const size = 64; - const radius = 18; - const canvas = document.createElement("canvas"); - canvas.width = size; - canvas.height = size; - const ctx = canvas.getContext("2d"); - if (!ctx) return null; +async function ensurePointAssetIcon(map: maplibregl.Map): Promise { + if (map.hasImage(DEFAULT_POINT_ICON_ID)) return true; - ctx.clearRect(0, 0, size, size); - - // soft shadow - ctx.fillStyle = "rgba(2, 6, 23, 0.28)"; - ctx.beginPath(); - ctx.arc(size / 2, size / 2 + 4, radius, 0, Math.PI * 2); - ctx.fill(); - - // icon body - ctx.fillStyle = spec.fill; - ctx.strokeStyle = spec.stroke; - ctx.lineWidth = 4; - ctx.beginPath(); - ctx.arc(size / 2, size / 2, radius, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - - // short type mark - ctx.fillStyle = "#ffffff"; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.font = spec.label.length > 1 ? "700 15px sans-serif" : "700 20px sans-serif"; - ctx.fillText(spec.label, size / 2, size / 2 + 0.5); - - return ctx.getImageData(0, 0, size, size); -} - -function buildPointIconExpression(): maplibregl.ExpressionSpecification { - const expression: unknown[] = ["match", getFeatureTypeExpression()]; - - for (const [typeId, iconId] of Object.entries(POINT_ICON_BY_TYPE)) { - expression.push(typeId, iconId); + try { + const image = await map.loadImage(POINT_ICON_URL); + if (!map.hasImage(DEFAULT_POINT_ICON_ID)) { + map.addImage(DEFAULT_POINT_ICON_ID, image.data); + } + return true; + } catch (error) { + console.error(`Failed to load point icon asset: ${POINT_ICON_URL}`, error); + return false; } - - expression.push(DEFAULT_POINT_ICON_ID); - return expression as maplibregl.ExpressionSpecification; } function buildTypeMatchExpression( diff --git a/lib/selectingEngine.ts b/lib/selectingEngine.ts index 100d41d..11772da 100644 --- a/lib/selectingEngine.ts +++ b/lib/selectingEngine.ts @@ -57,9 +57,11 @@ export function initSelect( // Chọn feature theo click trái, hỗ trợ additive bằng Alt. function onClick(e: maplibregl.MapLayerMouseEvent) { if (getMode() !== "select") return; + const selectableLayers = getSelectableLayers(); + if (!selectableLayers.length) return; const features = map.queryRenderedFeatures(e.point, { - layers: [...SELECTABLE_LAYERS], + layers: selectableLayers, }) as maplibregl.MapGeoJSONFeature[]; if (!features.length) { @@ -75,11 +77,13 @@ export function initSelect( // Mở menu thao tác khi click phải lên feature. function onRightClick(e: maplibregl.MapLayerMouseEvent) { if (getMode() !== "select") return; + const selectableLayers = getSelectableLayers(); + if (!selectableLayers.length) return; e.preventDefault(); // block browser menu const features = map.queryRenderedFeatures(e.point, { - layers: [...SELECTABLE_LAYERS], + layers: selectableLayers, }) as maplibregl.MapGeoJSONFeature[]; if (!features.length) return; @@ -104,14 +108,23 @@ export function initSelect( // Đổi cursor pointer khi hover lên đối tượng có thể chọn. function onMove(e: maplibregl.MapLayerMouseEvent) { if (getMode() !== "select") return; + const selectableLayers = getSelectableLayers(); + if (!selectableLayers.length) { + map.getCanvas().style.cursor = ""; + return; + } const features = map.queryRenderedFeatures(e.point, { - layers: [...SELECTABLE_LAYERS], + layers: selectableLayers, }); map.getCanvas().style.cursor = features.length ? "pointer" : ""; } + function getSelectableLayers(): string[] { + return SELECTABLE_LAYERS.filter((layerId) => Boolean(map.getLayer(layerId))); + } + map.on("click", onClick); map.on("mousemove", onMove); if (hasContextActions) {