add editor

This commit is contained in:
taDuc
2026-05-02 02:48:17 +07:00
parent 41af501b51
commit a74047fd09
62 changed files with 9049 additions and 9 deletions
+33
View File
@@ -0,0 +1,33 @@
import { API_ENDPOINTS } from "@/uhm/api/config";
import { jsonRequestInit, requestJson } from "@/uhm/api/http";
export type AuthTokens = {
access_token: string;
refresh_token: string;
};
export type CurrentUser = {
id: string;
email?: string;
display_name?: string;
avatar_url?: string | null;
roles?: string[];
};
export async function signIn(email: string, password: string): Promise<AuthTokens> {
const res = await requestJson<AuthTokens>(
API_ENDPOINTS.authSignin,
jsonRequestInit("POST", { email, password }),
// Sign-in sets httpOnly cookies in BackEndGo.
{ skipAuth: true }
);
return res;
}
export async function logout(): Promise<void> {
await requestJson(API_ENDPOINTS.authLogout, { method: "POST" });
}
export async function fetchCurrentUser(): Promise<CurrentUser> {
return requestJson<CurrentUser>(API_ENDPOINTS.currentUser);
}
+23
View File
@@ -0,0 +1,23 @@
// Production BackEndGo API base URL.
// For local development, override with NEXT_PUBLIC_API_BASE_URL (e.g. http://localhost:3344).
const FALLBACK_API_BASE_URL = "https://history-api.kain.id.vn";
export const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL || FALLBACK_API_BASE_URL;
export const API_ENDPOINTS = {
geometries: `${API_BASE_URL}/geometries`,
entities: `${API_BASE_URL}/entities`,
// New API uses projects + commits + submissions (JWT-protected).
authSignin: `${API_BASE_URL}/auth/signin`,
authRefresh: `${API_BASE_URL}/auth/refresh`,
authLogout: `${API_BASE_URL}/auth/logout`,
currentUser: `${API_BASE_URL}/users/current`,
currentUserProjects: `${API_BASE_URL}/users/current/project`,
projects: `${API_BASE_URL}/projects`,
submissions: `${API_BASE_URL}/submissions`,
vectorTiles: `${API_BASE_URL}/tiles/{z}/{x}/{y}`,
rasterTiles: `${API_BASE_URL}/raster-tiles/{z}/{x}/{y}`,
vectorTilesMetadata: `${API_BASE_URL}/tiles/metadata`,
rasterTilesMetadata: `${API_BASE_URL}/raster-tiles/metadata`,
} as const;
+32
View File
@@ -0,0 +1,32 @@
import { API_ENDPOINTS } from "@/uhm/api/config";
import { requestJson } from "@/uhm/api/http";
import type { Entity } from "@/uhm/types/entities";
export type { Entity } from "@/uhm/types/entities";
export async function fetchEntities(query?: { q?: string }): Promise<Entity[]> {
const params = new URLSearchParams();
// API mới dùng `name` thay vì `q`.
if (query?.q) {
params.set("name", query.q);
}
const suffix = params.toString();
const url = suffix ? `${API_ENDPOINTS.entities}?${suffix}` : API_ENDPOINTS.entities;
return requestJson<Entity[]>(url);
}
export async function searchEntitiesByName(
name: string,
options?: { limit?: number }
): Promise<Entity[]> {
const keyword = name.trim();
if (!keyword.length) return [];
const params = new URLSearchParams({ name: keyword });
if (options?.limit && Number.isFinite(options.limit)) {
params.set("limit", String(Math.trunc(options.limit)));
}
// API mới không có `/entities/search`, search qua query string.
return requestJson<Entity[]>(`${API_ENDPOINTS.entities}?${params.toString()}`);
}
+92
View File
@@ -0,0 +1,92 @@
import { API_ENDPOINTS } from "@/uhm/api/config";
import { requestJson } from "@/uhm/api/http";
import type { GeometriesBBoxQuery } from "@/uhm/types/api";
import type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo";
export type { GeometriesBBoxQuery } from "@/uhm/types/api";
function buildBBoxQueryString(params: GeometriesBBoxQuery): string {
const query = new URLSearchParams({
// API mới dùng snake_case
min_lng: String(params.minLng),
min_lat: String(params.minLat),
max_lng: String(params.maxLng),
max_lat: String(params.maxLat),
});
if (params.time !== undefined) {
query.set("time", String(params.time));
}
if (params.entity_id) {
query.set("entity_id", params.entity_id);
}
return query.toString();
}
export async function fetchGeometriesByBBox(params: GeometriesBBoxQuery): Promise<FeatureCollection> {
const url = `${API_ENDPOINTS.geometries}?${buildBBoxQueryString(params)}`;
// API mới trả về list geometries, FE cần chuyển thành GeoJSON FeatureCollection.
const rows = await requestJson<GeometryRow[]>(url);
return geometriesToFeatureCollection(rows);
}
type GeometryRow = {
id: string;
geo_type: string;
draw_geometry: unknown;
binding?: unknown;
time_start?: number;
time_end?: number;
bbox?: {
min_lng: number;
min_lat: number;
max_lng: number;
max_lat: number;
} | null;
};
function geometriesToFeatureCollection(rows: GeometryRow[]): FeatureCollection {
const features: Feature[] = [];
for (const row of rows || []) {
const geometry = normalizeGeometry(row.draw_geometry);
if (!geometry) continue;
const binding = normalizeBinding(row.binding);
const properties: FeatureProperties = {
id: row.id,
type: row.geo_type || null,
time_start: row.time_start ?? null,
time_end: row.time_end ?? null,
binding: binding.length ? binding : undefined,
};
features.push({
type: "Feature",
properties,
geometry,
});
}
return { type: "FeatureCollection", features };
}
function normalizeGeometry(value: unknown): Geometry | null {
if (!value || typeof value !== "object") return null;
const g = value as any;
if (typeof g.type !== "string") return null;
if (!("coordinates" in g)) return null;
return g as Geometry;
}
function normalizeBinding(value: unknown): string[] {
if (!value) return [];
if (Array.isArray(value)) {
return value.map((v) => String(v)).filter((v) => v.length > 0);
}
// Some deployments may return binding as an object; ignore it for FE properties.binding.
return [];
}
+154
View File
@@ -0,0 +1,154 @@
import type { ApiEnvelope } from "@/uhm/types/api";
import { API_ENDPOINTS } from "@/uhm/api/config";
export class ApiError extends Error {
status: number;
body: string;
errors: unknown[];
constructor(message: string, status: number, body: string, errors: unknown[] = []) {
super(message);
this.name = "ApiError";
this.status = status;
this.body = body;
this.errors = errors;
}
}
// BackEndGo auth flow: cookie-based (httpOnly access_token/refresh_token).
// We intentionally do not store bearer tokens in localStorage in this FE.
type RequestJsonOptions = {
skipAuth?: boolean;
skipRefresh?: boolean;
};
export async function requestJson<T>(
input: RequestInfo | URL,
init?: RequestInit,
options?: RequestJsonOptions
): Promise<T> {
return requestJsonInternal<T>(input, init, options);
}
export function jsonRequestInit(method: string, body: unknown): RequestInit {
return {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
};
}
async function requestJsonInternal<T>(
input: RequestInfo | URL,
init?: RequestInit,
options?: RequestJsonOptions
): Promise<T> {
const nextInit = withAuthHeaders(init, options);
const res = await fetch(input, nextInit);
// One-shot refresh + retry for protected endpoints.
if (
res.status === 401 &&
!options?.skipRefresh &&
!options?.skipAuth &&
typeof input === "string" &&
!String(input).includes("/auth/")
) {
const refreshed = await tryRefreshTokens();
if (refreshed) {
return requestJsonInternal<T>(input, init, { ...(options || {}), skipRefresh: true });
}
}
const payload = await parseJsonResponse(res);
const envelope = isApiEnvelopeLike<T>(payload) ? payload : null;
if (!res.ok) {
const message = extractErrorMessage(payload, envelope) || `Request failed with status ${res.status}`;
const body = envelope ? stringifyPayload(envelope) : stringifyPayload(payload);
const errors = envelope?.errors ? normalizeErrors(envelope.errors) : [];
throw new ApiError(message, res.status, body, errors);
}
if (envelope) {
const isError =
envelope.status === false ||
envelope.status === "error";
if (isError) {
const message = extractErrorMessage(payload, envelope) || "Request failed";
throw new ApiError(message, res.status, stringifyPayload(envelope), normalizeErrors(envelope.errors));
}
return (envelope.data ?? null) as T;
}
return payload as T;
}
async function parseJsonResponse(res: Response): Promise<unknown> {
const text = await res.text();
if (!text.length) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
function isApiEnvelopeLike<T>(value: unknown): value is ApiEnvelope<T> {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const source = value as Record<string, unknown>;
return "status" in source && ("data" in source || "message" in source || "errors" in source);
}
function normalizeErrors(value: unknown): unknown[] {
if (value == null) return [];
if (Array.isArray(value)) return value;
return [value];
}
function extractErrorMessage(payload: unknown, envelope: ApiEnvelope<unknown> | null): string | null {
const msg =
(typeof envelope?.message === "string" && envelope.message.trim()) ||
(typeof (payload as any)?.message === "string" && String((payload as any).message).trim());
if (msg) return msg;
const errors = envelope?.errors ?? (payload as any)?.errors;
if (typeof errors === "string" && errors.trim()) return errors.trim();
if (Array.isArray(errors) && typeof errors[0] === "string") return errors[0];
return null;
}
function stringifyPayload(payload: unknown): string {
if (typeof payload === "string") return payload;
try {
return JSON.stringify(payload);
} catch {
return String(payload);
}
}
function withAuthHeaders(init: RequestInit | undefined, options?: RequestJsonOptions): RequestInit | undefined {
const baseInit: RequestInit = {
...init,
// Always include cookies (BackEndGo sets httpOnly access_token/refresh_token cookies).
credentials: init?.credentials ?? "include",
};
// Cookie-based auth only.
// Keep the function so call sites don't change, but never inject Authorization headers.
if (options?.skipAuth) return baseInit;
return baseInit;
}
async function tryRefreshTokens(): Promise<boolean> {
try {
await requestJson(
API_ENDPOINTS.authRefresh,
{ method: "POST" },
{ skipAuth: true, skipRefresh: true }
);
return true;
} catch {
return false;
}
}
+187
View File
@@ -0,0 +1,187 @@
import { API_BASE_URL, API_ENDPOINTS } from "@/uhm/api/config";
import { jsonRequestInit, requestJson } from "@/uhm/api/http";
import type {
CreateCommitInput,
CreateSectionInput,
EditorLoadResponse,
RestoreCommitInput,
Section,
SectionCommit,
SectionState,
SectionSubmission,
} from "@/uhm/types/sections";
export type {
CreateCommitInput,
CreateSectionInput,
EditorLoadResponse,
RestoreCommitInput,
Section,
SectionCommit,
SectionState,
SectionSubmission,
} from "@/uhm/types/sections";
// Sections (API cũ) => Projects (API mới)
export async function fetchSections(): Promise<Section[]> {
// /users/current/project requires JWT.
return requestJson<Section[]>(API_ENDPOINTS.currentUserProjects);
}
export async function createSection(input: CreateSectionInput): Promise<Section> {
// POST /projects
return requestJson<Section>(API_ENDPOINTS.projects, jsonRequestInit("POST", input));
}
export async function openSectionEditor(sectionId: string): Promise<EditorLoadResponse> {
// API mới không có endpoint "editor". FE tự load:
// 1) Project details
// 2) Project commits (to get snapshot_json of latest commit)
const project = await requestJson<Section>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}`);
const commits = await fetchSectionCommits(sectionId);
const headCommitId = project.latest_commit_id ?? null;
const headCommit = headCommitId ? commits.find((c) => c.id === headCommitId) || null : null;
const snapshot = headCommit?.snapshot_json ?? null;
const state: SectionState = {
status: project.project_status || "ACTIVE",
head_commit_id: headCommitId,
locked_by: project.locked_by ?? null,
};
return {
section: project,
state,
commit: headCommit,
snapshot,
};
}
export async function createSectionCommit(
sectionId: string,
input: CreateCommitInput
): Promise<{ commit: SectionCommit; state: SectionState }> {
// POST /projects/{id}/commits
const commit = await requestJson<SectionCommit>(
`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}/commits`,
jsonRequestInit("POST", {
snapshot_json: input.snapshot,
edit_summary: input.edit_summary,
})
);
// Refresh project state (latest_commit_id may have moved).
const project = await requestJson<Section>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}`);
const state: SectionState = {
status: project.project_status || "ACTIVE",
head_commit_id: project.latest_commit_id ?? null,
locked_by: project.locked_by ?? null,
};
return { commit, state };
}
export async function fetchSectionCommits(sectionId: string): Promise<SectionCommit[]> {
return requestJson<SectionCommit[]>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}/commits`);
}
export async function restoreSectionCommit(
sectionId: string,
input: RestoreCommitInput
): Promise<{ commit: SectionCommit | null; state: SectionState }> {
// POST /projects/{id}/commits/restore
await requestJson(
`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}/commits/restore`,
jsonRequestInit("POST", { commit_id: input.commit_id })
);
// Reload commits + project to determine new head commit.
const project = await requestJson<Section>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}`);
const commits = await fetchSectionCommits(sectionId);
const headCommitId = project.latest_commit_id ?? null;
const headCommit = headCommitId ? commits.find((c) => c.id === headCommitId) || null : null;
const state: SectionState = {
status: project.project_status || "ACTIVE",
head_commit_id: headCommitId,
locked_by: project.locked_by ?? null,
};
return { commit: headCommit, state };
}
export async function submitSection(sectionId: string): Promise<SectionSubmission> {
// Submit latest commit of project
const project = await requestJson<Section>(`${API_ENDPOINTS.projects}/${encodeURIComponent(sectionId)}`);
const commitId = project.latest_commit_id;
if (!commitId) {
throw new Error("Project has no latest commit to submit");
}
return requestJson<SectionSubmission>(
API_ENDPOINTS.submissions,
jsonRequestInit("POST", {
project_id: sectionId,
commit_id: commitId,
content: "",
})
);
}
// API mới không có list submissions theo project kèm snapshot.
// FE dùng /submissions (admin/mod) hoặc fetch từng submission id.
export async function fetchSectionSubmissions(_sectionId: string): Promise<SectionSubmission[]> {
return [];
}
export async function searchSubmissions(query?: {
page?: number;
limit?: number;
statuses?: string[];
project_id?: string;
search?: string;
}): Promise<SectionSubmission[]> {
const params = new URLSearchParams();
if (query?.page) params.set("page", String(Math.trunc(query.page)));
if (query?.limit) params.set("limit", String(Math.trunc(query.limit)));
if (query?.project_id) params.set("project_id", query.project_id);
if (query?.search) params.set("search", query.search);
if (query?.statuses?.length) {
for (const s of query.statuses) params.append("statuses", s);
}
const suffix = params.toString();
const url = suffix ? `${API_ENDPOINTS.submissions}?${suffix}` : API_ENDPOINTS.submissions;
return requestJson<SectionSubmission[]>(url);
}
export async function approveSubmission(
submissionId: string,
input: { review_note: string }
): Promise<SectionSubmission> {
return requestJson<SectionSubmission>(
`${API_ENDPOINTS.submissions}/${encodeURIComponent(submissionId)}/status`,
jsonRequestInit("PATCH", {
status: "APPROVED",
review_note: input.review_note,
})
);
}
export async function rejectSubmission(
submissionId: string,
input: { review_note: string }
): Promise<SectionSubmission> {
return requestJson<SectionSubmission>(
`${API_ENDPOINTS.submissions}/${encodeURIComponent(submissionId)}/status`,
jsonRequestInit("PATCH", {
status: "REJECTED",
review_note: input.review_note,
})
);
}
// Convenience for runtime logs/debug: expose effective base.
export const EFFECTIVE_API_BASE_URL = API_BASE_URL;
+20
View File
@@ -0,0 +1,20 @@
import { API_ENDPOINTS } from "@/uhm/api/config";
import { requestJson } from "@/uhm/api/http";
export type TileMetadata = Record<string, string>;
export function getVectorTileTemplateUrl(): string {
return API_ENDPOINTS.vectorTiles;
}
export function getRasterTileTemplateUrl(): string {
return API_ENDPOINTS.rasterTiles;
}
export async function fetchVectorTilesMetadata(): Promise<TileMetadata> {
return requestJson<TileMetadata>(API_ENDPOINTS.vectorTilesMetadata);
}
export async function fetchRasterTilesMetadata(): Promise<TileMetadata> {
return requestJson<TileMetadata>(API_ENDPOINTS.rasterTilesMetadata);
}
+9
View File
@@ -0,0 +1,9 @@
"use client";
// FrontEndAdmin is the primary FE and follows BackEndGo cookie-based auth.
// Users sign in via the app's /signin page; the editor reuses those httpOnly cookies.
// This component remains as a no-op placeholder for any legacy imports.
export default function AuthPanel() {
return null;
}
@@ -0,0 +1,95 @@
"use client";
import { ReactNode } from "react";
import {
BACKGROUND_LAYER_OPTIONS,
BackgroundLayerId,
BackgroundLayerVisibility,
} from "@/uhm/lib/backgroundLayers";
type Props = {
visibility: BackgroundLayerVisibility;
onToggleLayer: (id: BackgroundLayerId) => void;
onShowAll: () => void;
onHideAll: () => void;
topContent?: ReactNode;
};
export default function BackgroundLayersPanel({
visibility,
onToggleLayer,
onShowAll,
onHideAll,
topContent,
}: Props) {
return (
<aside
style={{
width: "240px",
background: "#111827",
color: "#e5e7eb",
borderLeft: "1px solid #1f2937",
padding: "12px",
height: "100vh",
overflowY: "auto",
}}
>
{topContent ? <div style={{ marginBottom: "12px" }}>{topContent}</div> : null}
<h3 style={{ margin: 0, marginBottom: "10px" }}>Map Layers</h3>
<div style={{ display: "flex", gap: "8px", marginBottom: "12px" }}>
<button
onClick={onShowAll}
style={{
flex: 1,
border: "none",
borderRadius: "6px",
padding: "6px 8px",
cursor: "pointer",
background: "#374151",
color: "#f9fafb",
}}
>
Bật hết
</button>
<button
onClick={onHideAll}
style={{
flex: 1,
border: "none",
borderRadius: "6px",
padding: "6px 8px",
cursor: "pointer",
background: "#1f2937",
color: "#f9fafb",
}}
>
Tắt hết
</button>
</div>
<div style={{ display: "grid", gap: "8px" }}>
{BACKGROUND_LAYER_OPTIONS.map((layer) => (
<label
key={layer.id}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
fontSize: "14px",
cursor: "pointer",
}}
>
<input
type="checkbox"
checked={visibility[layer.id]}
onChange={() => onToggleLayer(layer.id)}
/>
<span>{layer.label}</span>
</label>
))}
</div>
</aside>
);
}
+349
View File
@@ -0,0 +1,349 @@
"use client";
import { useEffect, useMemo, type ComponentProps } from "react";
import Tree from "react-d3-tree";
export type CommitTreeItem = {
id: string;
parent_commit_id: string | null;
restored_from_commit_id: string | null;
commit_no: number;
kind: string;
created_by: string;
created_at: string;
title: string | null;
};
type CommitTreeNode = {
commit: CommitTreeItem;
children: CommitTreeNode[];
};
type CommitTreeDatum = {
name: string;
commit: CommitTreeItem;
isHead: boolean;
detail: string;
restoredFromLabel: string | null;
children?: CommitTreeDatum[];
};
type Props = {
open: boolean;
commits: CommitTreeItem[];
headCommitId: string | null;
onClose: () => void;
};
type TreeRenderNode = NonNullable<ComponentProps<typeof Tree>["renderCustomNodeElement"]>;
export default function CommitTreePopup({
open,
commits,
headCommitId,
onClose,
}: Props) {
const { roots, commitById } = useMemo(() => buildCommitTree(commits), [commits]);
const treeData = useMemo(
() => roots.map((node) => toTreeDatum(node, commitById, headCommitId)),
[roots, commitById, headCommitId]
);
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [open, onClose]);
if (!open) return null;
return (
<div
role="presentation"
onClick={onClose}
style={{
position: "fixed",
inset: 0,
zIndex: 1000,
background: "rgba(2, 6, 23, 0.72)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "24px",
}}
>
<section
role="dialog"
aria-modal="true"
aria-label="Commit tree"
onClick={(event) => event.stopPropagation()}
style={{
width: "min(1120px, calc(100vw - 48px))",
maxHeight: "min(720px, calc(100vh - 48px))",
overflow: "hidden",
border: "1px solid #334155",
borderRadius: "8px",
background: "#0f172a",
color: "#e2e8f0",
boxShadow: "0 24px 80px rgba(0, 0, 0, 0.45)",
display: "flex",
flexDirection: "column",
}}
>
<style>
{`
.commit-tree-link {
fill: none;
stroke: #ffffff;
stroke-width: 4px;
stroke-opacity: 1;
filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.75));
}
`}
</style>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
padding: "14px 16px",
borderBottom: "1px solid #1f2937",
}}
>
<div>
<div style={{ fontSize: "16px", fontWeight: 700, color: "#f8fafc" }}>
Commit tree
</div>
<div style={{ marginTop: "3px", fontSize: "12px", color: "#94a3b8" }}>
{commits.length} commit{commits.length === 1 ? "" : "s"}
</div>
</div>
<button
type="button"
onClick={onClose}
style={{
padding: "7px 10px",
border: "1px solid #475569",
borderRadius: "4px",
background: "#111827",
color: "#f8fafc",
cursor: "pointer",
}}
>
Close
</button>
</div>
<div
style={{
padding: "16px",
overflow: "auto",
}}
>
{treeData.length === 0 ? (
<div style={{ color: "#94a3b8", fontSize: "14px" }}>
Chưa commit.
</div>
) : (
<div
style={{
width: "100%",
minWidth: "640px",
height: "540px",
border: "1px solid #64748b",
borderRadius: "6px",
background: "#111827",
overflow: "hidden",
}}
>
<Tree
data={treeData}
orientation="vertical"
translate={{ x: 520, y: 56 }}
nodeSize={{ x: 300, y: 165 }}
separation={{ siblings: 1.15, nonSiblings: 1.45 }}
pathFunc="step"
collapsible={false}
zoomable
draggable
scaleExtent={{ min: 0.45, max: 1.4 }}
renderCustomNodeElement={renderCommitTreeNode}
pathClassFunc={() => "commit-tree-link"}
/>
</div>
)}
</div>
</section>
</div>
);
}
const renderCommitTreeNode: TreeRenderNode = function renderCommitTreeNode({ nodeDatum }) {
const datum = nodeDatum as unknown as CommitTreeDatum;
const commit = datum.commit;
const isHead = datum.isHead;
return (
<g>
<circle
r={8}
fill={isHead ? "#16a34a" : "#111827"}
stroke={isHead ? "#bbf7d0" : "#f8fafc"}
strokeWidth={3}
/>
<foreignObject x={-115} y={18} width={230} height={96}>
<div
style={{
width: "220px",
minHeight: "78px",
padding: "8px 9px",
border: isHead ? "2px solid #86efac" : "2px solid #e2e8f0",
borderRadius: "6px",
background: isHead ? "#14532d" : "#1f2937",
color: "#f8fafc",
fontSize: "12px",
lineHeight: 1.35,
boxSizing: "border-box",
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "6px",
minWidth: 0,
}}
>
<span style={{ color: "#f8fafc", fontWeight: 700 }}>
#{commit.commit_no}
</span>
{isHead ? (
<span
style={{
padding: "1px 5px",
border: "1px solid #22c55e",
borderRadius: "4px",
color: "#bbf7d0",
fontSize: "10px",
fontWeight: 700,
}}
>
HEAD
</span>
) : null}
</div>
<div
title={formatCommitTitle(commit)}
style={{
marginTop: "4px",
color: "#f8fafc",
fontWeight: 700,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{formatCommitTitle(commit)}
</div>
<div style={{ marginTop: "4px", color: "#94a3b8" }}>
{datum.detail}
</div>
{datum.restoredFromLabel ? (
<div
title={datum.restoredFromLabel}
style={{
marginTop: "3px",
color: "#93c5fd",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{datum.restoredFromLabel}
</div>
) : null}
</div>
</foreignObject>
</g>
);
};
function buildCommitTree(commits: CommitTreeItem[]) {
const commitById = new Map<string, CommitTreeItem>();
const nodeById = new Map<string, CommitTreeNode>();
for (const commit of commits) {
commitById.set(commit.id, commit);
nodeById.set(commit.id, { commit, children: [] });
}
const roots: CommitTreeNode[] = [];
for (const node of nodeById.values()) {
const parentId = getDisplayParentCommitId(node.commit);
const parent = parentId ? nodeById.get(parentId) : null;
if (parent) {
parent.children.push(node);
} else {
roots.push(node);
}
}
const sortNodes = (nodes: CommitTreeNode[]) => {
nodes.sort((a, b) => a.commit.commit_no - b.commit.commit_no);
for (const node of nodes) {
sortNodes(node.children);
}
};
sortNodes(roots);
return { roots, commitById };
}
function toTreeDatum(
node: CommitTreeNode,
commitById: Map<string, CommitTreeItem>,
headCommitId: string | null
): CommitTreeDatum {
const commit = node.commit;
const restoredFromCommit = commit.restored_from_commit_id
? commitById.get(commit.restored_from_commit_id) || null
: null;
const children = node.children.map((child) => toTreeDatum(child, commitById, headCommitId));
return {
name: formatCommitTitle(commit),
commit,
isHead: headCommitId === commit.id,
detail: `${commit.kind} by ${commit.created_by} - ${formatDateTime(commit.created_at)}`,
restoredFromLabel: restoredFromCommit
? `restored from #${restoredFromCommit.commit_no} ${formatCommitTitle(restoredFromCommit)}`
: null,
children: children.length ? children : undefined,
};
}
function getDisplayParentCommitId(commit: CommitTreeItem): string | null {
if (commit.kind === "restore" && commit.restored_from_commit_id) {
return commit.restored_from_commit_id;
}
return commit.parent_commit_id;
}
function formatCommitTitle(commit: CommitTreeItem): string {
return commit.title?.trim() || `Commit #${commit.commit_no}`;
}
function formatDateTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
+491
View File
@@ -0,0 +1,491 @@
"use client";
import { UndoAction } from "@/uhm/lib/useEditorState";
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
type Props = {
mode: EditorMode;
setMode: (mode: EditorMode) => void;
entityStatus?: string | null;
onUndo: () => void;
onCommit: () => void;
onSubmit: () => void;
onRestoreCommit: (commitId: string) => void;
isSaving: boolean;
isSubmitting: boolean;
sectionTitle: string;
sectionStatus: string;
commitTitle: string;
commitNote: string;
onCommitTitleChange: (title: string) => void;
onCommitNoteChange: (note: string) => void;
commitCount: number;
hasHeadCommit: boolean;
headCommitId: string | null;
latestCommitLabel: string | null;
commits: Array<{
id: string;
created_at?: string;
edit_summary: string;
user_id: string;
}>;
changesCount: number;
undoStack: UndoAction[];
createdEntities: Array<{
id: string;
name: string;
type_id?: string | null;
}>;
createdGeometries: Array<{
id: string | number;
geometryType: string;
semanticType?: string | null;
entityNames: string[];
}>;
};
export default function Editor({
mode,
setMode,
entityStatus,
onUndo,
onCommit,
onSubmit,
onRestoreCommit,
isSaving,
isSubmitting,
sectionTitle,
sectionStatus,
commitTitle,
commitNote,
onCommitTitleChange,
onCommitNoteChange,
commitCount,
hasHeadCommit,
headCommitId,
latestCommitLabel,
commits,
changesCount,
undoStack,
createdEntities,
createdGeometries,
}: Props) {
const formatCommitTitle = (commit: Props["commits"][number]) =>
commit.edit_summary?.trim() || `Commit ${commit.id.slice(0, 8)}`;
const toggleMode = (newMode: EditorMode) => {
if (mode === newMode) {
setMode("idle"); // bấm lại → tắt
} else {
setMode(newMode); // chuyển mode
}
};
// Lấy tối đa 8 tác vụ mới nhất, bỏ trùng nhãn (cùng loại/cùng id)
const recentUndoLabels = (() => {
const seen = new Set<string>();
const labels: string[] = [];
for (let i = undoStack.length - 1; i >= 0 && labels.length < 8; i -= 1) {
const label = formatUndoLabel(undoStack[i]);
if (seen.has(label)) continue;
seen.add(label);
labels.push(label);
}
return labels.reverse();
})();
const getButtonStyle = (btnMode: EditorMode) => ({
width: "100%",
padding: "8px",
marginBottom: "6px",
border: "none",
cursor: "pointer",
background: mode === btnMode ? "#4caf50" : "#222",
color: "white",
borderRadius: "4px",
});
return (
<div
style={{
width: "220px",
height: "100vh",
overflowY: "auto",
background: "#111",
color: "white",
padding: "12px",
borderRight: "1px solid #333",
}}
>
<h3 style={{ marginBottom: "10px" }}>Editor</h3>
<div
style={{
marginBottom: "12px",
padding: "10px",
background: "#0b1220",
borderRadius: "6px",
border: "1px solid #1f2937",
fontSize: "12px",
color: "#cbd5e1",
}}
>
<div style={{ color: "white", fontWeight: 600 }}>{sectionTitle}</div>
<div style={{ marginTop: "4px" }}>Status: {sectionStatus}</div>
<div>Commits: {commitCount}</div>
<div>{latestCommitLabel || "Chưa có commit"}</div>
</div>
<div
style={{
marginBottom: "12px",
padding: "10px",
background: "#0b1220",
borderRadius: "6px",
border: "1px solid #1f2937",
fontSize: "12px",
color: "#cbd5e1",
}}
>
<div style={{ marginBottom: "8px", fontWeight: 600, color: "white" }}>Project</div>
</div>
<button
style={getButtonStyle("draw")}
onClick={() => toggleMode("draw")}
>
Draw
</button>
<button
style={getButtonStyle("select")}
onClick={() => toggleMode("select")}
>
Select
</button>
<button
style={getButtonStyle("idle")}
onClick={() => setMode("idle")}
>
Idle
</button>
<button
style={getButtonStyle("add-point")}
onClick={() => setMode("add-point")}
>
Add point
</button>
<button
style={getButtonStyle("add-line")}
onClick={() => setMode("add-line")}
>
Add line
</button>
<button
style={getButtonStyle("add-path")}
onClick={() => setMode("add-path")}
>
Add path
</button>
<button
style={getButtonStyle("add-circle")}
onClick={() => setMode("add-circle")}
>
Add circle
</button>
<div style={{ marginTop: "12px", fontSize: "14px" }}>
Mode: <b>{mode}</b>
</div>
{mode === "add-line" ? (
<div style={{ marginTop: "6px", fontSize: "12px", color: "#93c5fd" }}>
Click đ thêm điểm, Enter đ hoàn tất, Esc đ hủy.
</div>
) : null}
{mode === "add-path" ? (
<div style={{ marginTop: "6px", fontSize: "12px", color: "#93c5fd" }}>
Click đ thêm điểm, Enter đ hoàn tất, Esc đ hủy.
</div>
) : null}
{mode === "add-circle" ? (
<div style={{ marginTop: "6px", fontSize: "12px", color: "#93c5fd" }}>
Giữ chuột trái kéo đ mở bán kính, thả chuột đ hoàn tất.
</div>
) : null}
{entityStatus ? (
<div
style={{
marginTop: "12px",
padding: "10px",
background: "#0b1220",
borderRadius: "6px",
border: "1px solid #1f2937",
color: "#fca5a5",
fontSize: "12px",
}}
>
{entityStatus}
</div>
) : null}
<div style={{ marginTop: "12px" }}>
<button
style={{
width: "100%",
padding: "8px",
borderRadius: "4px",
border: "none",
cursor: "pointer",
background: "#334155",
color: "white",
}}
onClick={onUndo}
>
Undo
</button>
</div>
<input
value={commitTitle}
onChange={(event) => onCommitTitleChange(event.target.value)}
placeholder="Commit title"
disabled={isSaving || isSubmitting}
style={{
width: "100%",
marginTop: "8px",
padding: "7px",
borderRadius: "4px",
border: "1px solid #334155",
background: "#111827",
color: "white",
boxSizing: "border-box",
}}
/>
<textarea
value={commitNote}
onChange={(event) => onCommitNoteChange(event.target.value)}
placeholder="Commit note"
disabled={isSaving || isSubmitting}
rows={3}
style={{
width: "100%",
marginTop: "8px",
padding: "7px",
borderRadius: "4px",
border: "1px solid #334155",
background: "#111827",
color: "white",
boxSizing: "border-box",
resize: "vertical",
fontFamily: "inherit",
}}
/>
<div style={{ display: "grid", gridTemplateColumns: "1fr 68px", gap: "8px", marginTop: "8px" }}>
<button
style={{
width: "100%",
padding: "8px",
borderRadius: "4px",
border: "none",
cursor: isSaving || isSubmitting ? "not-allowed" : "pointer",
background: isSaving || isSubmitting ? "#555" : "#0f766e",
color: "white",
}}
onClick={onCommit}
disabled={isSaving || isSubmitting}
>
Commit ({changesCount})
</button>
<div />
</div>
<button
style={{
width: "100%",
marginTop: "8px",
padding: "8px",
borderRadius: "4px",
border: "none",
cursor: isSubmitting || !hasHeadCommit ? "not-allowed" : "pointer",
background: isSubmitting || !hasHeadCommit ? "#555" : "#16a34a",
color: "white",
opacity: !hasHeadCommit ? 0.6 : 1,
}}
onClick={onSubmit}
disabled={isSubmitting || !hasHeadCommit}
>
Submit
</button>
<div
style={{
marginTop: "16px",
padding: "10px",
background: "#0b1220",
borderRadius: "6px",
border: "1px solid #1f2937",
}}
>
<div style={{ marginBottom: "8px", fontWeight: 600, fontSize: "14px" }}>
Commit history
</div>
{commits.length === 0 ? (
<div style={{ color: "#64748b", fontSize: "12px" }}>
Chưa commit
</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "12px" }}>
{commits.slice(0, 8).map((commit) => (
<li
key={commit.id}
style={{
padding: "6px 0",
borderBottom: "1px solid #1f2937",
color: "#e2e8f0",
}}
>
<div
title={formatCommitTitle(commit)}
style={{
fontWeight: 600,
color: "#f8fafc",
overflowWrap: "anywhere",
}}
>
{formatCommitTitle(commit)}
</div>
<div style={{ marginTop: "2px", color: "#94a3b8" }}>
{commit.created_at ? new Date(commit.created_at).toLocaleString() : ""}
</div>
<button
style={{
marginTop: "4px",
padding: "4px 6px",
borderRadius: "4px",
border: "none",
background: "#334155",
color: "white",
cursor: isSaving || isSubmitting ? "not-allowed" : "pointer",
}}
onClick={() => onRestoreCommit(commit.id)}
disabled={isSaving || isSubmitting}
>
Restore
</button>
</li>
))}
</ul>
)}
</div>
<div
style={{
marginTop: "16px",
padding: "10px",
background: "#0b1220",
borderRadius: "6px",
border: "1px solid #1f2937",
}}
>
<div style={{ marginBottom: "6px", fontWeight: 600, fontSize: "14px" }}>
Tác vụ thể undo ({recentUndoLabels.length})
</div>
{recentUndoLabels.length === 0 ? (
<div style={{ color: "#94a3b8", fontSize: "13px" }}>Chưa thao tác</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "13px", color: "#e2e8f0" }}>
{recentUndoLabels.map((label, idx) => (
<li key={`${label}-${idx}`} style={{ padding: "4px 0", borderBottom: "1px solid #1f2937" }}>
{label}
</li>
))}
</ul>
)}
</div>
<div
style={{
marginTop: "16px",
padding: "10px",
background: "#0b1220",
borderRadius: "6px",
border: "1px solid #1f2937",
}}
>
<div style={{ marginBottom: "8px", fontWeight: 600, fontSize: "14px" }}>
Mới tạo trong phiên
</div>
<div style={{ fontSize: "13px", color: "#cbd5e1", marginBottom: "6px" }}>
Entities ({createdEntities.length})
</div>
{createdEntities.length === 0 ? (
<div style={{ color: "#64748b", fontSize: "12px", marginBottom: "10px" }}>
Chưa tạo entity mới
</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "12px", marginBottom: "10px" }}>
{createdEntities.map((entity) => (
<li
key={entity.id}
style={{
padding: "4px 0",
borderBottom: "1px solid #1f2937",
color: "#e2e8f0",
}}
title={entity.id}
>
{entity.name} ({entity.type_id || "country"})
</li>
))}
</ul>
)}
<div style={{ fontSize: "13px", color: "#cbd5e1", marginBottom: "6px" }}>
Geometries mới chưa commit ({createdGeometries.length})
</div>
{createdGeometries.length === 0 ? (
<div style={{ color: "#64748b", fontSize: "12px" }}>
Chưa geometry mới chờ commit
</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "12px" }}>
{createdGeometries.map((geometry) => (
<li
key={String(geometry.id)}
style={{
padding: "4px 0",
borderBottom: "1px solid #1f2937",
color: "#e2e8f0",
}}
>
#{geometry.id} [{geometry.geometryType}] {geometry.semanticType ? `- ${geometry.semanticType}` : ""}
{geometry.entityNames.length ? ` | ${geometry.entityNames.join(", ")}` : ""}
</li>
))}
</ul>
)}
</div>
</div>
);
}
function formatUndoLabel(action: UndoAction) {
switch (action.type) {
case "create":
return `Thêm mới #${action.id}`;
case "delete":
return `Xóa #${action.feature.properties.id}`;
case "update":
return `Chỉnh sửa #${action.id}`;
case "properties":
return `Cập nhật thuộc tính #${action.id}`;
default:
return "Tác vụ";
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,493 @@
"use client";
import { type CSSProperties } from "react";
import { Entity } from "@/uhm/api/entities";
import { Feature } from "@/uhm/lib/useEditorState";
import {
EntityGeometryPreset,
EntityTypeGroupId,
EntityTypeOption,
findEntityTypeOption,
groupEntityTypeOptions,
} from "@/uhm/lib/entityTypeOptions";
import type { EntityFormState, GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
type Props = {
selectedFeature: Feature | null;
selectedFeatureEntitySummary: string;
selectedFeatureBindingSummary: string;
entities: Entity[];
selectedGeometryEntityIds: string[];
onEntityIdsChange: (values: string[]) => void;
entitySearchQuery: string;
onEntitySearchQueryChange: (value: string) => void;
entitySearchResults: Entity[];
selectedSearchEntityId: string | null;
onSelectSearchEntityId: (value: string | null) => void;
onAddSelectedSearchEntity: () => void;
isEntitySearchLoading: boolean;
entityForm: EntityFormState;
onEntityFormChange: (key: keyof EntityFormState, value: string) => void;
entityTypeOptions: EntityTypeOption[];
geometryMetaForm: GeometryMetaFormState;
onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void;
isEntitySubmitting: boolean;
onCreateEntityOnly: () => void;
onApplyGeometryMetadata: () => void;
onApplyEntitiesForSelectedGeometry: () => void;
changeCount: number;
entityFormStatus: string | null;
};
export default function SelectedGeometryPanel({
selectedFeature,
selectedFeatureEntitySummary,
selectedFeatureBindingSummary,
entities,
selectedGeometryEntityIds,
onEntityIdsChange,
entitySearchQuery,
onEntitySearchQueryChange,
entitySearchResults,
selectedSearchEntityId,
onSelectSearchEntityId,
onAddSelectedSearchEntity,
isEntitySearchLoading,
entityForm,
onEntityFormChange,
entityTypeOptions,
geometryMetaForm,
onGeometryMetaFormChange,
isEntitySubmitting,
onCreateEntityOnly,
onApplyGeometryMetadata,
onApplyEntitiesForSelectedGeometry,
changeCount,
entityFormStatus,
}: Props) {
const groupedEntityTypeOptions = groupEntityTypeOptions(entityTypeOptions);
const featureGeometryPreset = selectedFeature
? resolveFeatureGeometryPreset(selectedFeature)
: null;
const allowedGroupIds = featureGeometryPreset
? getAllowedGroupIdsForPreset(featureGeometryPreset)
: [];
const visibleGroupedEntityTypeOptions = groupedEntityTypeOptions.filter((group) =>
allowedGroupIds.includes(group.id)
);
const groupedEntityTypeOptionsForCreate = selectedFeature
? visibleGroupedEntityTypeOptions
: groupedEntityTypeOptions;
const selectedTypeOption = findEntityTypeOption(entityForm.type_id);
const hasCurrentVisibleTypeOption = groupedEntityTypeOptionsForCreate.some((group) =>
group.options.some((option) => option.value === entityForm.type_id)
);
return (
<div
style={{
padding: "10px",
background: "#0b1220",
borderRadius: "8px",
border: "1px solid #1f2937",
}}
>
<div style={{ fontWeight: 700, marginBottom: "8px", fontSize: "14px" }}>
Entity & Geometry
</div>
{!selectedFeature ? (
<div style={{ color: "#94a3b8", fontSize: "13px" }}>
Chưa chọn geometry. Tạo entity mới khối bên dưới, hoặc vào mode Select đ bind entity cho geometry.
</div>
) : (
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
<div style={{ color: "#e2e8f0" }}>
ID: {String(selectedFeature.properties.id)}
</div>
<div style={{ color: "#cbd5e1" }}>
Entities hiện tại: {selectedFeatureEntitySummary}
</div>
<div style={{ color: "#cbd5e1" }}>
Binding hiện tại: {selectedFeatureBindingSummary}
</div>
<div style={{ color: "#cbd5e1" }}>
Geometry preset: {formatGeometryPresetLabel(featureGeometryPreset)}
</div>
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
Entities đã chọn:
</div>
{selectedGeometryEntityIds.length ? (
<div style={{ display: "grid", gap: "6px" }}>
{selectedGeometryEntityIds.map((entityId) => {
const entity = entities.find((item) => item.id === entityId) || null;
const label = entity?.name
? `${entity.name} (${entityId})`
: entityId;
return (
<div
key={entityId}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "8px",
background: "#111827",
border: "1px solid #334155",
borderRadius: "6px",
padding: "6px 8px",
}}
>
<span style={{ color: "#e2e8f0" }}>{label}</span>
<button
type="button"
onClick={() =>
onEntityIdsChange(
selectedGeometryEntityIds.filter((id) => id !== entityId)
)
}
disabled={isEntitySubmitting}
style={removeButtonStyle}
>
Bỏ
</button>
</div>
);
})}
</div>
) : (
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
Chưa entity nào đưc gắn.
</div>
)}
<div
style={{
display: "grid",
gap: "8px",
border: "1px solid #243244",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ color: "#e2e8f0", fontWeight: 700, fontSize: "12px" }}>
Thuộc tính GEO
</div>
<div style={{ color: "#94a3b8", fontSize: "11px" }}>
Các giá trị này thuộc về GEO đang chọn, không phụ thuộc entity.
</div>
<input
value={geometryMetaForm.time_start}
onChange={(event) => onGeometryMetaFormChange("time_start", event.target.value)}
placeholder="time_start"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={geometryMetaForm.time_end}
onChange={(event) => onGeometryMetaFormChange("time_end", event.target.value)}
placeholder="time_end"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<button
type="button"
onClick={onApplyGeometryMetadata}
disabled={isEntitySubmitting}
style={primaryGeometryButtonStyle}
>
Apply
</button>
</div>
<div
style={{
display: "grid",
gap: "8px",
border: "1px solid #1f3b5a",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
Bind entity sẵn
</div>
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
Dùng khi entity đã tồn tại. Tìm kiếm, thêm vào danh sách rồi bấm nút áp dụng.
</div>
<input
value={entitySearchQuery}
onChange={(event) => onEntitySearchQueryChange(event.target.value)}
placeholder="Search entity theo name..."
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<select
value={selectedSearchEntityId || ""}
onChange={(event) =>
onSelectSearchEntityId(event.target.value ? event.target.value : null)
}
disabled={isEntitySubmitting || isEntitySearchLoading}
style={entityInputStyle}
>
<option value="">-- Chọn entity từ kết quả search --</option>
{entitySearchResults.map((entity) => (
<option key={entity.id} value={entity.id}>
{entity.name} ({entity.id})
</option>
))}
</select>
<button
type="button"
onClick={onAddSelectedSearchEntity}
disabled={isEntitySubmitting || isEntitySearchLoading}
style={secondaryActionButtonStyle}
>
Thêm entity đã chọn vào danh sách gắn
</button>
{isEntitySearchLoading ? (
<div style={{ color: "#93c5fd", fontSize: "12px" }}>
Đang tìm entity...
</div>
) : null}
<button
onClick={onApplyEntitiesForSelectedGeometry}
disabled={isEntitySubmitting}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
background: "#0f766e",
color: "#ffffff",
opacity: isEntitySubmitting ? 0.7 : 1,
fontWeight: 600,
}}
>
Áp dụng danh sách entity
</button>
</div>
{changeCount > 0 ? (
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
Thay đi sẽ vào lịch sử khi Commit.
</div>
) : null}
</div>
)}
<div
style={{
display: "grid",
gap: "8px",
border: "1px solid #1e3a8a",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
marginTop: "10px",
}}
>
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
Tạo entity mới (đc lập)
</div>
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
Chỉ tạo entity, không tự bind vào geometry.
</div>
{selectedFeature ? (
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
Type đang bị giới hạn theo geometry: <b>{formatGeometryPresetLabel(featureGeometryPreset)}</b>.
</div>
) : null}
<input
value={entityForm.name}
onChange={(event) => onEntityFormChange("name", event.target.value)}
placeholder="Tên entity mới"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={entityForm.slug}
onChange={(event) => onEntityFormChange("slug", event.target.value)}
placeholder="Slug"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
Chọn loại entity
</div>
<select
value={entityForm.type_id}
onChange={(event) => onEntityFormChange("type_id", event.target.value)}
disabled={isEntitySubmitting}
style={entityInputStyle}
>
{!selectedFeature && !hasCurrentVisibleTypeOption && entityForm.type_id ? (
<option value={entityForm.type_id}>
Custom Type ({entityForm.type_id})
</option>
) : null}
{groupedEntityTypeOptionsForCreate.map((group) => (
<optgroup
key={group.id}
label={`${group.label} (${group.geometryLabel})`}
>
{group.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</optgroup>
))}
</select>
{selectedTypeOption ? (
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
Type đang chọn: <b>{selectedTypeOption.label}</b> ({selectedTypeOption.groupLabel})
</div>
) : entityForm.type_id ? (
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
Type đang chọn: <b>{entityForm.type_id}</b>
</div>
) : null}
<button
onClick={onCreateEntityOnly}
disabled={isEntitySubmitting}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
background: "#2563eb",
color: "#ffffff",
opacity: isEntitySubmitting ? 0.7 : 1,
fontWeight: 600,
}}
>
Tạo entity mới
</button>
</div>
{entityFormStatus ? (
<div style={{ color: "#93c5fd", fontSize: "12px", marginTop: "8px" }}>
{entityFormStatus}
</div>
) : null}
</div>
);
}
const entityInputStyle: CSSProperties = {
width: "100%",
borderRadius: "6px",
border: "1px solid #334155",
background: "#111827",
color: "#f8fafc",
padding: "6px 8px",
fontSize: "13px",
};
const removeButtonStyle: CSSProperties = {
border: "none",
borderRadius: "6px",
padding: "4px 8px",
cursor: "pointer",
background: "#7f1d1d",
color: "#ffffff",
fontSize: "12px",
};
const secondaryActionButtonStyle: CSSProperties = {
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: "pointer",
background: "#1d4ed8",
color: "#ffffff",
};
const primaryGeometryButtonStyle: CSSProperties = {
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: "pointer",
background: "#0f766e",
color: "#ffffff",
fontWeight: 600,
};
function resolveFeatureGeometryPreset(feature: Feature): EntityGeometryPreset {
const explicitPreset = normalizeGeometryPreset(feature.properties.geometry_preset);
if (explicitPreset) return explicitPreset;
const semanticType = normalizeTypeId(feature.properties.type) || normalizeTypeId(feature.properties.entity_type_id);
if (semanticType) {
const option = findEntityTypeOption(semanticType);
if (option) return option.geometryPreset;
}
return mapGeometryTypeToPreset(feature.geometry.type);
}
function normalizeGeometryPreset(value: unknown): EntityGeometryPreset | null {
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
if (
normalized === "point" ||
normalized === "line" ||
normalized === "polygon" ||
normalized === "circle-area"
) {
return normalized;
}
return null;
}
function normalizeTypeId(value: unknown): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
return normalized.length ? normalized : null;
}
function mapGeometryTypeToPreset(
geometryType: Feature["geometry"]["type"]
): EntityGeometryPreset {
if (geometryType === "Point" || geometryType === "MultiPoint") {
return "point";
}
if (geometryType === "LineString" || geometryType === "MultiLineString") {
return "line";
}
return "polygon";
}
function getAllowedGroupIdsForPreset(
geometryPreset: EntityGeometryPreset
): EntityTypeGroupId[] {
if (geometryPreset === "point") {
return ["point"];
}
if (geometryPreset === "line") {
return ["line"];
}
if (geometryPreset === "circle-area") {
return ["circle"];
}
return ["polygon"];
}
function formatGeometryPresetLabel(preset: EntityGeometryPreset | null): string {
if (preset === "point") return "point - Điểm";
if (preset === "line") return "line - Tuyến";
if (preset === "circle-area") return "circle - Tròn";
if (preset === "polygon") return "polygon - Đa giác";
return "unknown";
}
+140
View File
@@ -0,0 +1,140 @@
"use client";
import { FIXED_TIMELINE_END_YEAR, FIXED_TIMELINE_START_YEAR, clampYearValue } from "@/uhm/lib/timeline";
type Props = {
year: number;
onYearChange: (year: number) => void;
isLoading: boolean;
disabled: boolean;
statusText?: string | null;
};
export default function TimelineBar({
year,
onYearChange,
isLoading,
disabled,
statusText,
}: Props) {
const lower = FIXED_TIMELINE_START_YEAR;
const upper = FIXED_TIMELINE_END_YEAR;
const effectiveDisabled = disabled;
const safeYear = clampYearValue(year, lower, upper);
const helperText = isLoading
? "Đang tải geometry theo mốc thời gian..."
: statusText || "Kéo thanh hoặc nhập số năm để query chính xác.";
const handleYearChange = (nextYear: number) => {
onYearChange(clampYearValue(Math.trunc(nextYear), lower, upper));
};
return (
<div
style={{
position: "absolute",
left: "18px",
right: "18px",
bottom: "16px",
zIndex: 10,
background: "rgba(15, 23, 42, 0.9)",
border: "1px solid rgba(148, 163, 184, 0.3)",
borderRadius: "10px",
padding: "12px 14px",
color: "#e2e8f0",
backdropFilter: "blur(2px)",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "8px",
gap: "8px",
}}
>
<span style={{ fontSize: "13px", fontWeight: 600, letterSpacing: "0.02em" }}>
Timeline
</span>
<span style={{ fontSize: "16px", fontWeight: 700, color: "#f8fafc" }}>
{formatYear(safeYear)}
</span>
</div>
<div style={{ fontSize: "12px", color: "#cbd5e1", marginTop: "8px", marginBottom: "6px" }}>
Mốc thời gian chi tiết
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) 120px",
alignItems: "center",
gap: "10px",
}}
>
<input
type="range"
min={lower}
max={upper}
step={1}
value={safeYear}
onChange={(event) => handleYearChange(Number(event.target.value))}
disabled={effectiveDisabled}
aria-label="Timeline year"
style={{
width: "100%",
accentColor: "#22c55e",
cursor: effectiveDisabled ? "not-allowed" : "pointer",
opacity: effectiveDisabled ? 0.6 : 1,
}}
/>
<input
type="number"
min={lower}
max={upper}
step={1}
value={safeYear}
onChange={(event) => handleYearChange(Number(event.target.value))}
disabled={effectiveDisabled}
aria-label="Timeline exact year"
style={{
width: "100%",
border: "1px solid rgba(148, 163, 184, 0.45)",
borderRadius: "6px",
padding: "6px 8px",
background: "rgba(15, 23, 42, 0.7)",
color: "#f8fafc",
fontSize: "13px",
outline: "none",
}}
/>
</div>
<div
style={{
marginTop: "8px",
display: "grid",
gridTemplateColumns: "1fr auto 1fr",
alignItems: "center",
columnGap: "10px",
fontSize: "12px",
}}
>
<span style={{ color: "#94a3b8" }}>{formatYear(lower)}</span>
<span style={{ color: "#cbd5e1", textAlign: "center", whiteSpace: "nowrap" }}>
{helperText}
</span>
<span style={{ color: "#94a3b8", textAlign: "right" }}>{formatYear(upper)}</span>
</div>
</div>
);
}
function formatYear(year: number): string {
if (year < 0) {
return `${Math.abs(year)} TCN`;
}
return `${year}`;
}
+28
View File
@@ -0,0 +1,28 @@
export const BACKGROUND_LAYER_OPTIONS = [
{ id: "raster-base-layer", label: "Raster" },
{ id: "graticules-line", label: "Graticules" },
{ id: "land", label: "Land" },
{ id: "bg-countries-fill", label: "Countries" },
{ id: "bg-country-borders-line", label: "Country Borders" },
{ id: "regions-line", label: "Regions" },
{ id: "lakes-fill", label: "Lakes" },
{ id: "rivers-line", label: "Rivers" },
{ id: "geolines-line", label: "Geolines" },
] as const;
export type BackgroundLayerId = (typeof BACKGROUND_LAYER_OPTIONS)[number]["id"];
export type BackgroundLayerVisibility = Record<BackgroundLayerId, boolean>;
// Tạo map visibility mặc định cho toàn bộ background layers.
function buildBackgroundLayerVisibility(value: boolean): BackgroundLayerVisibility {
return BACKGROUND_LAYER_OPTIONS.reduce((acc, option) => {
acc[option.id] = value;
return acc;
}, {} as BackgroundLayerVisibility);
}
export const DEFAULT_BACKGROUND_LAYER_VISIBILITY =
buildBackgroundLayerVisibility(true);
export const HIDDEN_BACKGROUND_LAYER_VISIBILITY =
buildBackgroundLayerVisibility(false);
@@ -0,0 +1,58 @@
import {
BACKGROUND_LAYER_OPTIONS,
BackgroundLayerVisibility,
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
} from "@/uhm/lib/backgroundLayers";
const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1";
export function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility {
if (typeof window === "undefined") {
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
}
try {
const raw = window.localStorage.getItem(BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY);
if (!raw) {
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
}
const parsed = JSON.parse(raw) as unknown;
const normalized = normalizeBackgroundLayerVisibility(parsed);
return normalized || { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
} catch (err) {
console.warn("Load background layer visibility from storage failed", err);
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
}
}
export function persistBackgroundLayerVisibility(visibility: BackgroundLayerVisibility) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY,
JSON.stringify(visibility)
);
} catch (err) {
console.warn("Persist background layer visibility failed", err);
}
}
function normalizeBackgroundLayerVisibility(raw: unknown): BackgroundLayerVisibility | null {
if (!raw || typeof raw !== "object") return null;
const source = raw as Record<string, unknown>;
const next: BackgroundLayerVisibility = {
...DEFAULT_BACKGROUND_LAYER_VISIBILITY,
};
for (const layer of BACKGROUND_LAYER_OPTIONS) {
const value = source[layer.id];
if (typeof value === "boolean") {
next[layer.id] = value;
}
}
return next;
}
+55
View File
@@ -0,0 +1,55 @@
import type {
Feature,
FeatureCollection,
FeatureProperties,
Geometry,
} from "@/uhm/types/geo";
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
export const deepClone = <T,>(obj: T): T => JSON.parse(JSON.stringify(obj));
export function geometryEquals(a: Geometry | undefined, b: Geometry | undefined): boolean {
if (!a || !b) return false;
return JSON.stringify(a) === JSON.stringify(b);
}
export function featureEquals(a: Feature | undefined, b: Feature | undefined): boolean {
if (!a || !b) return false;
return JSON.stringify(a.geometry) === JSON.stringify(b.geometry) &&
JSON.stringify(a.properties) === JSON.stringify(b.properties);
}
export function buildInitialMap(fc: FeatureCollection) {
const map = new Map<FeatureProperties["id"], Feature>();
for (const feature of fc.features) {
map.set(feature.properties.id, deepClone(feature));
}
return map;
}
export function diffDraftToInitial(
draft: FeatureCollection,
initialMap: Map<FeatureProperties["id"], Feature>
) {
const next = new Map<FeatureProperties["id"], Change>();
const seen = new Set<FeatureProperties["id"]>();
for (const feature of draft.features) {
const id = feature.properties.id;
seen.add(id);
const initialFeature = initialMap.get(id);
if (!initialFeature) {
next.set(id, { action: "create", feature: deepClone(feature) });
} else if (!featureEquals(initialFeature, feature)) {
next.set(id, { action: "update", id, geometry: deepClone(feature.geometry) });
}
}
for (const [id] of initialMap.entries()) {
if (!seen.has(id)) {
next.set(id, { action: "delete", id });
}
}
return next;
}
+15
View File
@@ -0,0 +1,15 @@
import type {
Feature,
FeatureProperties,
Geometry,
GeometryChange,
} from "@/uhm/types/geo";
export type Change = GeometryChange;
export type UndoAction =
| { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry }
| { type: "properties"; id: FeatureProperties["id"]; prevProperties: FeatureProperties }
| { type: "delete"; feature: Feature }
| { type: "create"; id: FeatureProperties["id"] };
+31
View File
@@ -0,0 +1,31 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { FeatureCollection } from "@/uhm/types/geo";
import { deepClone } from "@/uhm/lib/editor/draft/draftDiff";
export function useDraftState(initialData: FeatureCollection) {
// Draft hiện tại (React state) để UI re-render khi dữ liệu thay đổi.
const [draft, setDraft] = useState<FeatureCollection>(() => deepClone(initialData));
// Draft ref để đọc giá trị mới nhất trong event handlers/engines mà không cần deps.
const draftRef = useRef<FeatureCollection>(deepClone(initialData));
const commitDraft = useCallback((nextDraft: FeatureCollection) => {
const cloned = deepClone(nextDraft);
draftRef.current = cloned;
setDraft(cloned);
}, []);
useEffect(() => {
draftRef.current = draft;
}, [draft]);
const resetDraft = useCallback((nextDraft: FeatureCollection) => {
commitDraft(nextDraft);
}, [commitDraft]);
return {
draft,
draftRef,
commitDraft,
resetDraft,
};
}
+81
View File
@@ -0,0 +1,81 @@
import { useCallback, useState } from "react";
import type { UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
import { geometryEquals } from "@/uhm/lib/editor/draft/draftDiff";
type Options = {
applyUndoAction: (action: UndoAction) => boolean;
};
export function useUndoStack(options: Options) {
const { applyUndoAction } = options;
// Stack thao tác undo (append-only, pop khi undo).
const [undoStack, setUndoStack] = useState<UndoAction[]>([]);
const pushUndo = useCallback((action: UndoAction) => {
setUndoStack((prev) => {
const last = prev[prev.length - 1];
if (isSameUndo(last, action)) return prev;
return [...prev, action];
});
}, []);
const undo = useCallback(() => {
let applied = false;
setUndoStack((prev) => {
if (applied) return prev;
if (!prev.length) return prev;
const last = prev[prev.length - 1];
const remaining = prev.slice(0, -1);
applied = true;
const didApply = applyUndoAction(last);
return didApply ? remaining : prev;
});
}, [applyUndoAction]);
const clearUndo = useCallback(() => {
setUndoStack([]);
}, []);
return {
undoStack,
pushUndo,
undo,
clearUndo,
};
}
function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
if (!a) return false;
if (a.type !== b.type) return false;
switch (a.type) {
case "create": {
const next = b as Extract<UndoAction, { type: "create" }>;
return a.id === next.id;
}
case "delete": {
const next = b as Extract<UndoAction, { type: "delete" }>;
return (
a.feature.properties.id === next.feature.properties.id &&
geometryEquals(a.feature.geometry, next.feature.geometry)
);
}
case "update": {
const next = b as Extract<UndoAction, { type: "update" }>;
return (
a.id === next.id &&
geometryEquals(a.prevGeometry, next.prevGeometry)
);
}
case "properties": {
const next = b as Extract<UndoAction, { type: "properties" }>;
return (
a.id === next.id &&
JSON.stringify(a.prevProperties) === JSON.stringify(next.prevProperties)
);
}
default:
return false;
}
}
+109
View File
@@ -0,0 +1,109 @@
import type { Entity } from "@/uhm/types/entities";
import type { Feature, FeatureProperties } from "@/uhm/types/geo";
import type { PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes";
import { normalizeFeatureEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot";
export function mergeEntitiesWithPending(
persistedEntities: Entity[],
pendingCreates: PendingEntityCreate[]
): Entity[] {
if (!pendingCreates.length) {
return persistedEntities;
}
const seen = new Set<string>();
const pendingAsEntities: Entity[] = [];
for (const pending of pendingCreates) {
if (seen.has(pending.id)) continue;
seen.add(pending.id);
pendingAsEntities.push({
id: pending.id,
name: pending.name,
slug: pending.slug,
type_id: pending.type_id,
status: pending.status,
geometry_count: 0,
created_at: undefined,
updated_at: undefined,
});
}
const nextPersisted = persistedEntities.filter((entity) => !seen.has(entity.id));
return [...pendingAsEntities, ...nextPersisted];
}
export function mergeEntitySearchResults(
remoteRows: Entity[],
localRows: Entity[]
): Entity[] {
const merged: Entity[] = [];
const seen = new Set<string>();
for (const row of localRows) {
if (!row.id || seen.has(row.id)) continue;
seen.add(row.id);
merged.push(row);
}
for (const row of remoteRows) {
if (!row.id || seen.has(row.id)) continue;
seen.add(row.id);
merged.push(row);
}
return merged;
}
export function formatEntityNamesForDisplay(feature: Feature, entities: Entity[]): string {
const entityIds = normalizeFeatureEntityIds(feature);
if (!entityIds.length) return "Chưa gắn";
const names = entityIds
.map((id) => entities.find((entity) => entity.id === id)?.name || id)
.filter((name) => name.trim().length > 0);
return names.join(", ");
}
export function buildClientEntityId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `entity-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
}
export function buildFeatureEntityPatch(
feature: Feature,
entityIds: string[],
entities: Entity[]
): Partial<FeatureProperties> {
const primaryEntityId = entityIds[0] || null;
const primaryEntity = primaryEntityId
? entities.find((entity) => entity.id === primaryEntityId) || null
: null;
const nextGeometryType = resolveGeometryTypeFromEntityIds(entityIds, entities) ||
feature.properties.type ||
null;
const entityNames = entityIds
.map((id) => entities.find((entity) => entity.id === id)?.name || "")
.filter((name) => name.length > 0);
return {
type: nextGeometryType,
entity_id: primaryEntityId,
entity_ids: entityIds,
entity_name: primaryEntity?.name || null,
entity_names: entityNames,
entity_type_id: primaryEntity?.type_id || null,
};
}
function resolveGeometryTypeFromEntityIds(
entityIds: string[],
entities: Entity[]
): string | null {
const primaryEntityId = entityIds[0] || null;
if (!primaryEntityId) return null;
const primaryEntity = entities.find((entity) => entity.id === primaryEntityId) || null;
return primaryEntity?.type_id || null;
}
@@ -0,0 +1,50 @@
import type { Feature, FeatureProperties } from "@/uhm/types/geo";
import type { GeometryMetaFormState } from "@/uhm/lib/editor/session/sessionTypes";
import {
normalizeFeatureBindingIds,
parseBindingInput,
} from "@/uhm/lib/editor/snapshot/editorSnapshot";
export type GeometryMetadataPatch = {
patch: Partial<FeatureProperties>;
formState: GeometryMetaFormState;
};
export function buildGeometryMetadataPatch(form: GeometryMetaFormState): GeometryMetadataPatch {
const timeStart = parseOptionalYearInput(form.time_start, "time_start");
const timeEnd = parseOptionalYearInput(form.time_end, "time_end");
if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) {
throw new Error("time_start phải <= time_end.");
}
const bindingIds = parseBindingInput(form.binding);
return {
patch: {
time_start: timeStart,
time_end: timeEnd,
binding: bindingIds,
},
formState: {
time_start: timeStart != null ? String(timeStart) : "",
time_end: timeEnd != null ? String(timeEnd) : "",
binding: bindingIds.join(", "),
},
};
}
export function formatBindingIdsForDisplay(feature: Feature): string {
const bindingIds = normalizeFeatureBindingIds(feature);
if (!bindingIds.length) return "Không có";
return bindingIds.join(", ");
}
function parseOptionalYearInput(raw: string, fieldName: string): number | null {
const value = raw.trim();
if (!value.length) return null;
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
throw new Error(`${fieldName} phải là số.`);
}
return Math.trunc(parsed);
}
@@ -0,0 +1,257 @@
import { useCallback } from "react";
import type { Dispatch, SetStateAction } from "react";
import { ApiError } from "@/uhm/api/http";
import {
createSection,
createSectionCommit,
fetchSectionCommits,
fetchSections,
openSectionEditor,
restoreSectionCommit,
submitSection,
} from "@/uhm/api/sections";
import { buildEditorSnapshot, normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
import type { CreatedEntitySummary, PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes";
import type { Feature, FeatureCollection, FeatureId } from "@/uhm/types/geo";
import type { EditorSnapshot, Section, SectionCommit, SectionState } from "@/uhm/types/sections";
type EditorDraftApi = {
draft: FeatureCollection;
buildPayload: () => Change[];
clearChanges: () => void;
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
};
type Options = {
editor: EditorDraftApi;
editorUserId: string;
emptyFeatureCollection: FeatureCollection;
activeSection: Section | null;
sectionState: SectionState | null;
selectedSectionId: string;
newSectionTitle: string;
pendingSaveCount: number;
pendingEntityCreates: PendingEntityCreate[];
lastSectionSnapshot: EditorSnapshot | null;
commitTitle: string;
commitNote: string;
setActiveSection: Dispatch<SetStateAction<Section | null>>;
setSelectedSectionId: Dispatch<SetStateAction<string>>;
setSectionState: Dispatch<SetStateAction<SectionState | null>>;
setLastSectionSnapshot: Dispatch<SetStateAction<EditorSnapshot | null>>;
setInitialData: Dispatch<SetStateAction<FeatureCollection>>;
setSectionCommits: Dispatch<SetStateAction<SectionCommit[]>>;
setPendingEntityCreates: Dispatch<SetStateAction<PendingEntityCreate[]>>;
setCreatedEntities: Dispatch<SetStateAction<CreatedEntitySummary[]>>;
setSelectedFeatureId: Dispatch<SetStateAction<FeatureId | null>>;
setEntityFormStatus: Dispatch<SetStateAction<string | null>>;
setEntityStatus: Dispatch<SetStateAction<string | null>>;
setIsSaving: Dispatch<SetStateAction<boolean>>;
setIsSubmitting: Dispatch<SetStateAction<boolean>>;
setIsOpeningSection: Dispatch<SetStateAction<boolean>>;
setAvailableSections: Dispatch<SetStateAction<Section[]>>;
setNewSectionTitle: Dispatch<SetStateAction<string>>;
setCommitTitle: Dispatch<SetStateAction<string>>;
setCommitNote: Dispatch<SetStateAction<string>>;
};
export function useSectionCommands(options: Options) {
const openSectionForEditing = useCallback(async (sectionId: string) => {
const editorPayload = await openSectionEditor(sectionId);
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
const commits = await fetchSectionCommits(sectionId);
const nextInitialData = snapshot?.editor_feature_collection || options.emptyFeatureCollection;
options.setActiveSection(editorPayload.section);
options.setSelectedSectionId(editorPayload.section.id);
options.setSectionState(editorPayload.state);
options.setLastSectionSnapshot(snapshot);
options.setInitialData(nextInitialData);
options.setSectionCommits(commits);
options.setPendingEntityCreates([]);
options.setCreatedEntities([]);
options.setSelectedFeatureId(null);
options.setEntityFormStatus(null);
}, [options]);
const commitSection = useCallback(async () => {
if (!options.activeSection || !options.sectionState) {
options.setEntityStatus("Chưa mở được section editor.");
return;
}
const geometryChanges = options.editor.buildPayload();
options.setIsSaving(true);
options.setEntityStatus(null);
try {
const snapshot = buildEditorSnapshot({
section: options.activeSection,
draft: options.editor.draft,
changes: geometryChanges,
pendingEntities: options.pendingEntityCreates,
previousSnapshot: options.lastSectionSnapshot,
hasPersistedFeature: options.editor.hasPersistedFeature,
});
const result = await createSectionCommit(options.activeSection.id, {
snapshot,
edit_summary: options.commitTitle.trim()
|| options.commitNote.trim()
|| `Edit ${new Date().toLocaleString()}`,
});
options.setSectionState(result.state);
options.setLastSectionSnapshot(snapshot);
options.setInitialData(options.editor.draft);
options.editor.clearChanges();
options.setPendingEntityCreates([]);
options.setCreatedEntities([]);
options.setCommitTitle("");
options.setCommitNote("");
options.setSectionCommits(await fetchSectionCommits(options.activeSection.id));
options.setEntityFormStatus("Đã tạo commit.");
} catch (err) {
if (err instanceof ApiError) {
console.error("Commit failed", err.body);
options.setEntityStatus(`Commit thất bại: ${err.body}`);
return;
}
console.error("Commit error", err);
options.setEntityStatus("Commit thất bại.");
} finally {
options.setIsSaving(false);
}
}, [options]);
const openSelectedSection = useCallback(async () => {
const sectionId = options.selectedSectionId.trim();
if (!sectionId) {
options.setEntityStatus("Hãy chọn section để mở.");
return;
}
if (options.pendingSaveCount > 0) {
const confirmed = window.confirm("Section hiện tại có thay đổi chưa Commit. Mở section khác sẽ bỏ các thay đổi này. Tiếp tục?");
if (!confirmed) return;
}
options.setIsOpeningSection(true);
options.setEntityStatus(null);
try {
await openSectionForEditing(sectionId);
options.setEntityStatus("Đã mở section để chỉnh sửa.");
} catch (err) {
if (err instanceof ApiError) {
options.setEntityStatus(`Mở section thất bại: ${err.body}`);
} else {
options.setEntityStatus("Mở section thất bại.");
}
} finally {
options.setIsOpeningSection(false);
}
}, [openSectionForEditing, options]);
const createAndOpenSection = useCallback(async () => {
const title = options.newSectionTitle.trim();
if (!title) {
options.setEntityStatus("Tên section là bắt buộc.");
return;
}
if (options.pendingSaveCount > 0) {
const confirmed = window.confirm("Section hiện tại có thay đổi chưa Commit. Tạo section mới sẽ bỏ các thay đổi này. Tiếp tục?");
if (!confirmed) return;
}
options.setIsOpeningSection(true);
options.setEntityStatus(null);
try {
const section = await createSection({
title,
description: null,
});
const sections = await fetchSections();
options.setAvailableSections(sections);
options.setNewSectionTitle("");
await openSectionForEditing(section.id);
options.setEntityStatus("Đã tạo và mở section mới.");
} catch (err) {
if (err instanceof ApiError) {
options.setEntityStatus(`Tạo section thất bại: ${err.body}`);
} else {
options.setEntityStatus("Tạo section thất bại.");
}
} finally {
options.setIsOpeningSection(false);
}
}, [openSectionForEditing, options]);
const submitCurrentSection = useCallback(async () => {
if (!options.activeSection || !options.sectionState?.head_commit_id) {
options.setEntityStatus("Section hiện tại chưa có head để submit.");
return;
}
if (options.pendingSaveCount > 0) {
options.setEntityStatus("Hãy Commit các thay đổi trước khi Submit.");
return;
}
options.setIsSubmitting(true);
options.setEntityStatus(null);
try {
const submission = await submitSection(options.activeSection.id);
options.setEntityStatus(`Đã submit, submission ${submission.id}.`);
} catch (err) {
if (err instanceof ApiError) {
options.setEntityStatus(`Submit thất bại: ${err.body}`);
} else {
options.setEntityStatus("Submit thất bại.");
}
} finally {
options.setIsSubmitting(false);
}
}, [options]);
const restoreCommit = useCallback(async (commitId: string) => {
if (!options.activeSection || !options.sectionState) {
options.setEntityStatus("Chưa mở được section editor.");
return;
}
if (options.pendingSaveCount > 0) {
options.setEntityStatus("Hãy Commit hoặc Undo thay đổi hiện tại trước khi Restore.");
return;
}
options.setIsSaving(true);
options.setEntityStatus(null);
try {
const result = await restoreSectionCommit(options.activeSection.id, {
commit_id: commitId,
});
const editorPayload = await openSectionEditor(options.activeSection.id);
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
options.setSectionState(result.state);
options.setLastSectionSnapshot(snapshot);
if (snapshot?.editor_feature_collection) {
options.setInitialData(snapshot.editor_feature_collection);
}
options.setSectionCommits(await fetchSectionCommits(options.activeSection.id));
options.setEntityFormStatus("Đã restore commit.");
} catch (err) {
if (err instanceof ApiError) {
options.setEntityStatus(`Restore thất bại: ${err.body}`);
} else {
options.setEntityStatus("Restore thất bại.");
}
} finally {
options.setIsSaving(false);
}
}, [options]);
return {
openSectionForEditing,
commitSection,
openSelectedSection,
createAndOpenSection,
submitCurrentSection,
restoreCommit,
};
}
@@ -0,0 +1,44 @@
import type { EntityGeometryPreset } from "@/uhm/lib/entityTypeOptions";
export type EditorMode =
| "idle"
| "draw"
| "select"
| "add-point"
| "add-line"
| "add-path"
| "add-circle";
export type TimelineRange = {
min: number;
max: number;
};
export type EntityFormState = {
name: string;
slug: string;
type_id: string;
};
export type GeometryMetaFormState = {
time_start: string;
time_end: string;
binding: string;
};
export type PendingEntityCreate = {
id: string;
name: string;
slug: string | null;
type_id: string;
status: number;
};
export type CreatedEntitySummary = {
id: string;
name: string;
type_id?: string | null;
};
export type GeometryPreset = EntityGeometryPreset;
@@ -0,0 +1,21 @@
import { useState } from "react";
import {
BackgroundLayerVisibility,
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
} from "@/uhm/lib/backgroundLayers";
export function useBackgroundSessionState() {
// Trạng thái bật/tắt layer nền (khởi tạo default hidden; sẽ load từ storage ở page).
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
);
// Đảm bảo đã load visibility trước khi render map thật.
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
return {
backgroundVisibility,
setBackgroundVisibility,
isBackgroundVisibilityReady,
setIsBackgroundVisibilityReady,
};
}
@@ -0,0 +1,80 @@
import { useState } from "react";
import type { Entity } from "@/uhm/types/entities";
import type { FeatureId } from "@/uhm/types/geo";
import { DEFAULT_ENTITY_TYPE_ID } from "@/uhm/lib/entityTypeOptions";
import type {
CreatedEntitySummary,
EntityFormState,
GeometryMetaFormState,
PendingEntityCreate,
} from "@/uhm/lib/editor/session/sessionTypes";
export function useEntitySessionState() {
// Entities đã persisted từ backend (dùng cho search/binding).
const [persistedEntities, setPersistedEntities] = useState<Entity[]>([]);
// Entities tạo mới trong phiên nhưng chưa commit lên backend.
const [pendingEntityCreates, setPendingEntityCreates] = useState<PendingEntityCreate[]>([]);
// Tóm tắt entities đã tạo (để hiển thị nhanh ở sidebar).
const [createdEntities, setCreatedEntities] = useState<CreatedEntitySummary[]>([]);
// Thông báo trạng thái/lỗi liên quan entity/session.
const [entityStatus, setEntityStatus] = useState<string | null>(null);
// Feature đang được chọn để thao tác bind entities/metadata.
const [selectedFeatureId, setSelectedFeatureId] = useState<FeatureId | null>(null);
// Form tạo entity mới (độc lập).
const [entityForm, setEntityForm] = useState<EntityFormState>({
name: "",
slug: "",
type_id: DEFAULT_ENTITY_TYPE_ID,
});
// Danh sách entity IDs đang chọn để bind vào geometry hiện tại.
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
// Form metadata geometry (time range + binding ids).
const [geometryMetaForm, setGeometryMetaForm] = useState<GeometryMetaFormState>({
time_start: "",
time_end: "",
binding: "",
});
// Cờ loading khi apply entity/metadata (local submit).
const [isEntitySubmitting, setIsEntitySubmitting] = useState(false);
// Thông báo trạng thái/lỗi cho form entity/metadata.
const [entityFormStatus, setEntityFormStatus] = useState<string | null>(null);
// Keyword search entity theo name.
const [entitySearchQuery, setEntitySearchQuery] = useState("");
// Kết quả search entity để user chọn.
const [entitySearchResults, setEntitySearchResults] = useState<Entity[]>([]);
// Entity ID đang được chọn trong dropdown kết quả search.
const [selectedSearchEntityId, setSelectedSearchEntityId] = useState<string | null>(null);
// Cờ loading khi search entity.
const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false);
return {
persistedEntities,
setPersistedEntities,
pendingEntityCreates,
setPendingEntityCreates,
createdEntities,
setCreatedEntities,
entityStatus,
setEntityStatus,
selectedFeatureId,
setSelectedFeatureId,
entityForm,
setEntityForm,
selectedGeometryEntityIds,
setSelectedGeometryEntityIds,
geometryMetaForm,
setGeometryMetaForm,
isEntitySubmitting,
setIsEntitySubmitting,
entityFormStatus,
setEntityFormStatus,
entitySearchQuery,
setEntitySearchQuery,
entitySearchResults,
setEntitySearchResults,
selectedSearchEntityId,
setSelectedSearchEntityId,
isEntitySearchLoading,
setIsEntitySearchLoading,
};
}
@@ -0,0 +1,85 @@
import { useCallback, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import type { EditorSnapshot, Section, SectionCommit, SectionState } from "@/uhm/types/sections";
type Options = {
defaultEditorUserId: string;
};
type SectionTask = "idle" | "saving" | "submitting" | "opening-section";
export function useSectionSessionState(options: Options) {
// Single state machine cho các tác vụ async của section (saving/submitting/opening).
const [sectionTask, setSectionTask] = useState<SectionTask>("idle");
const setTaskFlag = useCallback((task: Exclude<SectionTask, "idle">, next: SetStateAction<boolean>) => {
setSectionTask((prev) => {
const currentValue = prev === task;
const nextValue = typeof next === "function" ? next(currentValue) : next;
if (nextValue) return task;
return prev === task ? "idle" : prev;
});
}, []);
const isSaving = sectionTask === "saving";
const isSubmitting = sectionTask === "submitting";
const isOpeningSection = sectionTask === "opening-section";
const setIsSaving: Dispatch<SetStateAction<boolean>> = useCallback((next) => {
setTaskFlag("saving", next);
}, [setTaskFlag]);
const setIsSubmitting: Dispatch<SetStateAction<boolean>> = useCallback((next) => {
setTaskFlag("submitting", next);
}, [setTaskFlag]);
const setIsOpeningSection: Dispatch<SetStateAction<boolean>> = useCallback((next) => {
setTaskFlag("opening-section", next);
}, [setTaskFlag]);
// Danh sách sections để user chọn mở.
const [availableSections, setAvailableSections] = useState<Section[]>([]);
// Section ID đang được chọn trong dropdown.
const [selectedSectionId, setSelectedSectionId] = useState("");
// Title section mới (để create).
const [newSectionTitle, setNewSectionTitle] = useState("");
// Input title cho commit.
const [commitTitle, setCommitTitle] = useState("");
// Input note cho commit.
const [commitNote, setCommitNote] = useState("");
// User ID dùng để gắn vào commit/submit/lock.
const [editorUserIdInput, setEditorUserIdInput] = useState(options.defaultEditorUserId);
// Section đang mở để edit (null nếu chưa mở).
const [activeSection, setActiveSection] = useState<Section | null>(null);
// Trạng thái section (version/head/status/lock).
const [sectionState, setSectionState] = useState<SectionState | null>(null);
// Danh sách commits của section đang mở.
const [sectionCommits, setSectionCommits] = useState<SectionCommit[]>([]);
// Snapshot gần nhất đã load (để build snapshot diff/metadata).
const [lastSectionSnapshot, setLastSectionSnapshot] = useState<EditorSnapshot | null>(null);
return {
isSaving,
setIsSaving,
isSubmitting,
setIsSubmitting,
isOpeningSection,
setIsOpeningSection,
availableSections,
setAvailableSections,
selectedSectionId,
setSelectedSectionId,
newSectionTitle,
setNewSectionTitle,
commitTitle,
setCommitTitle,
commitNote,
setCommitNote,
editorUserIdInput,
setEditorUserIdInput,
activeSection,
setActiveSection,
sectionState,
setSectionState,
sectionCommits,
setSectionCommits,
lastSectionSnapshot,
setLastSectionSnapshot,
};
}
@@ -0,0 +1,42 @@
import { useState } from "react";
import type { TimelineRange } from "@/uhm/lib/editor/session/sessionTypes";
import { clampYearValue } from "@/uhm/lib/timeline";
type Options = {
currentYear: number;
fallbackTimelineRange: TimelineRange;
};
export function useTimelineState(options: Options) {
// Năm timeline "đã chốt" để fetch dữ liệu.
const [timelineYear, setTimelineYear] = useState<number>(() =>
clampYearValue(
options.currentYear,
options.fallbackTimelineRange.min,
options.fallbackTimelineRange.max
)
);
// Năm timeline đang chỉnh (debounce rồi đẩy sang timelineYear).
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() =>
clampYearValue(
options.currentYear,
options.fallbackTimelineRange.min,
options.fallbackTimelineRange.max
)
);
// Cờ loading khi fetch theo timeline.
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
// Thông báo trạng thái/lỗi khi fetch theo timeline.
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
return {
timelineYear,
setTimelineYear,
timelineDraftYear,
setTimelineDraftYear,
isTimelineLoading,
setIsTimelineLoading,
timelineStatus,
setTimelineStatus,
};
}
@@ -0,0 +1,254 @@
import { DEFAULT_ENTITY_TYPE_ID } from "@/uhm/lib/entityTypeOptions";
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
import type { PendingEntityCreate } from "@/uhm/lib/editor/session/sessionTypes";
import type { EntitySnapshot } from "@/uhm/types/entities";
import type { Feature, FeatureCollection, GeometrySnapshot, LinkScopeSnapshot } from "@/uhm/types/geo";
import type { EditorSnapshot, Section } from "@/uhm/types/sections";
export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const snapshot = raw as EditorSnapshot;
if (
snapshot.editor_feature_collection &&
snapshot.editor_feature_collection.type === "FeatureCollection" &&
Array.isArray(snapshot.editor_feature_collection.features)
) {
return snapshot;
}
return {
...snapshot,
editor_feature_collection: undefined,
};
}
export function buildEditorSnapshot(options: {
section: Section;
draft: FeatureCollection;
changes: Change[];
pendingEntities: PendingEntityCreate[];
previousSnapshot: EditorSnapshot | null;
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
}): EditorSnapshot {
const changedIds = new Set(options.changes.map((change) =>
String(change.action === "create" ? change.feature.properties.id : change.id)
));
const deletedIds = new Set(
options.changes
.filter((change): change is Extract<Change, { action: "delete" }> => change.action === "delete")
.map((change) => String(change.id))
);
const currentDraftIds = new Set(options.draft.features.map((feature) => String(feature.properties.id)));
const previousFeatures = new globalThis.Map<string, Feature>();
for (const feature of options.previousSnapshot?.editor_feature_collection?.features || []) {
previousFeatures.set(String(feature.properties.id), feature);
if (!currentDraftIds.has(String(feature.properties.id))) {
deletedIds.add(String(feature.properties.id));
}
}
const previousGeometryOps = new globalThis.Map<string, GeometrySnapshot["operation"]>();
for (const item of options.previousSnapshot?.geometries || []) {
const id = typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : "";
const operation = item.operation;
if (id && operation) previousGeometryOps.set(id, operation);
}
const pendingEntityIds = new Set(options.pendingEntities.map((entity) => entity.id));
const entityRows = new globalThis.Map<string, EntitySnapshot>();
for (const item of options.previousSnapshot?.entities || []) {
const id = typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : "";
if (id) entityRows.set(id, { ...item });
}
for (const entity of options.pendingEntities) {
entityRows.set(entity.id, {
id: entity.id,
operation: "create",
name: entity.name,
slug: entity.slug,
description: null,
type_id: entity.type_id,
status: entity.status,
is_deleted: 0,
});
}
for (const feature of options.draft.features) {
for (const entityId of normalizeFeatureEntityIds(feature)) {
if (entityRows.has(entityId)) continue;
entityRows.set(entityId, {
id: entityId,
operation: "reference",
name: feature.properties.entity_names?.[0] || feature.properties.entity_name || entityId,
slug: null,
description: null,
type_id: feature.properties.entity_type_id || feature.properties.type || DEFAULT_ENTITY_TYPE_ID,
status: 1,
is_deleted: 0,
});
}
}
const geometries: GeometrySnapshot[] = options.draft.features.map((feature) => {
const id = String(feature.properties.id);
const previousOperation = previousGeometryOps.get(id);
const previousFeature = previousFeatures.get(id);
const changedFromPreviousSnapshot = previousFeature
? JSON.stringify(previousFeature) !== JSON.stringify(feature)
: false;
const operation: GeometrySnapshot["operation"] = previousOperation === "create"
? "create"
: !previousFeature && (options.previousSnapshot || !options.hasPersistedFeature(feature.properties.id))
? "create"
: changedIds.has(id) || changedFromPreviousSnapshot
? "update"
: "reference";
const bbox = getFeatureBBox(feature);
return {
id,
operation,
type: feature.properties.type || getDefaultTypeIdForFeature(feature),
draw_geometry: feature.geometry,
binding: normalizeFeatureBindingIds(feature),
time_start: feature.properties.time_start ?? null,
time_end: feature.properties.time_end ?? null,
bbox: bbox
? {
min_lng: bbox.minLng,
min_lat: bbox.minLat,
max_lng: bbox.maxLng,
max_lat: bbox.maxLat,
}
: null,
is_deleted: 0,
};
});
for (const id of deletedIds) {
geometries.push({
id,
operation: "delete",
is_deleted: 1,
});
}
const linkScopes: LinkScopeSnapshot[] = options.draft.features
.map((feature) => ({
geometry_id: String(feature.properties.id),
operation: "replace" as const,
entity_ids: normalizeFeatureEntityIds(feature),
}))
.filter((scope) => scope.entity_ids.length > 0);
return {
schema_version: 1,
section: {
id: options.section.id,
title: options.section.title,
},
editor_feature_collection: JSON.parse(JSON.stringify(options.draft)) as FeatureCollection,
entities: Array.from(entityRows.values()).map((entity) => {
const id = String(entity.id || "");
if (pendingEntityIds.has(id)) return entity;
return entity;
}),
geometries,
link_scopes: linkScopes,
};
}
export function getDefaultTypeIdForFeature(feature: Feature): string {
const preset = feature.properties.geometry_preset;
if (preset === "line") return "defense_line";
if (preset === "point") return "city";
if (preset === "circle-area") return "war";
if (preset === "polygon") return DEFAULT_ENTITY_TYPE_ID;
const geometryType = feature.geometry.type;
if (geometryType === "LineString" || geometryType === "MultiLineString") {
return "defense_line";
}
if (geometryType === "Point" || geometryType === "MultiPoint") {
return "city";
}
return DEFAULT_ENTITY_TYPE_ID;
}
export function normalizeFeatureEntityIds(feature: Feature): string[] {
const fromArray = Array.isArray(feature.properties.entity_ids)
? feature.properties.entity_ids.filter((id): id is string => typeof id === "string" && id.trim().length > 0)
: [];
if (fromArray.length) {
return uniqueEntityIds(fromArray);
}
const single = feature.properties.entity_id;
if (typeof single === "string" && single.trim().length > 0) {
return [single.trim()];
}
return [];
}
export function normalizeFeatureBindingIds(feature: Feature): string[] {
const rawBinding = feature.properties.binding;
if (!Array.isArray(rawBinding)) return [];
return uniqueEntityIds(rawBinding
.map((id) => {
if (typeof id !== "string" && typeof id !== "number") return "";
return String(id).trim();
})
.filter((id) => id.length > 0));
}
export function parseBindingInput(raw: string): string[] {
if (!raw.trim().length) return [];
return uniqueEntityIds(
raw
.split(/[,\n]/)
.map((item) => item.trim())
.filter((item) => item.length > 0)
);
}
export function uniqueEntityIds(ids: string[]): string[] {
const deduped: string[] = [];
const seen = new Set<string>();
for (const rawId of ids) {
const id = rawId.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
deduped.push(id);
}
return deduped;
}
function getFeatureBBox(feature: Feature): { minLng: number; minLat: number; maxLng: number; maxLat: number } | null {
const points = collectCoordinatePairs(feature.geometry.coordinates);
if (!points.length) return null;
let minLng = Number.POSITIVE_INFINITY;
let minLat = Number.POSITIVE_INFINITY;
let maxLng = Number.NEGATIVE_INFINITY;
let maxLat = Number.NEGATIVE_INFINITY;
for (const [lng, lat] of points) {
minLng = Math.min(minLng, lng);
minLat = Math.min(minLat, lat);
maxLng = Math.max(maxLng, lng);
maxLat = Math.max(maxLat, lat);
}
return { minLng, minLat, maxLng, maxLat };
}
function collectCoordinatePairs(value: unknown): Array<[number, number]> {
if (!Array.isArray(value)) return [];
if (
value.length >= 2 &&
typeof value[0] === "number" &&
typeof value[1] === "number" &&
Number.isFinite(value[0]) &&
Number.isFinite(value[1])
) {
return [[value[0], value[1]]];
}
return value.flatMap((item) => collectCoordinatePairs(item));
}
+247
View File
@@ -0,0 +1,247 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/useEditorState";
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
const EARTH_RADIUS_METERS = 6371008.8;
const CIRCLE_SEGMENTS = 72;
const MIN_RADIUS_METERS = 1;
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
type: "FeatureCollection",
features: [],
};
// Khởi tạo engine vẽ circle bằng thao tác kéo chuột từ tâm ra biên.
export function initCircle(
map: maplibregl.Map,
getMode: ModeGetter,
onComplete: (geometry: Geometry) => void
) {
let center: [number, number] | null = null;
let radiusMeters = 0;
let isDragging = false;
let dragPanDisabledByCircle = false;
// Xóa dữ liệu preview circle trên map.
const clearPreview = () => {
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
EMPTY_PREVIEW
);
};
// Bật lại drag pan nếu trước đó bị tắt khi đang kéo vẽ circle.
const releaseDragPan = () => {
if (!dragPanDisabledByCircle) return;
dragPanDisabledByCircle = false;
if (!map.dragPan.isEnabled()) {
map.dragPan.enable();
}
};
// Reset toàn bộ trạng thái vẽ circle tạm thời.
const resetDrawingState = () => {
center = null;
radiusMeters = 0;
isDragging = false;
clearPreview();
releaseDragPan();
};
// Cập nhật polygon preview theo tâm và bán kính hiện tại.
const updatePreview = () => {
if (!center || radiusMeters < MIN_RADIUS_METERS) {
clearPreview();
return;
}
const ring = buildCircleRing(center, radiusMeters, CIRCLE_SEGMENTS);
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: {},
geometry: {
type: "Polygon",
coordinates: [ring],
},
},
],
});
};
// Bắt đầu phiên vẽ circle khi nhấn chuột trái.
const onMouseDown = (e: maplibregl.MapMouseEvent) => {
if (getMode() !== "add-circle") return;
if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return;
center = [e.lngLat.lng, e.lngLat.lat];
radiusMeters = 0;
isDragging = true;
clearPreview();
if (map.dragPan.isEnabled()) {
map.dragPan.disable();
dragPanDisabledByCircle = true;
} else {
dragPanDisabledByCircle = false;
}
};
// Cập nhật bán kính theo vị trí chuột trong lúc kéo.
const onMouseMove = (e: maplibregl.MapMouseEvent) => {
const canvas = map.getCanvas();
if (getMode() !== "add-circle") {
if (canvas.style.cursor === "crosshair") {
canvas.style.cursor = "";
}
if (isDragging) {
resetDrawingState();
}
return;
}
canvas.style.cursor = "crosshair";
if (!isDragging || !center) return;
radiusMeters = distanceMeters(center, [e.lngLat.lng, e.lngLat.lat]);
updatePreview();
};
// Hoàn tất circle và trả geometry cho callback.
const finishCircle = () => {
if (!isDragging || !center) {
resetDrawingState();
return;
}
if (radiusMeters < MIN_RADIUS_METERS) {
resetDrawingState();
return;
}
const ring = buildCircleRing(center, radiusMeters, CIRCLE_SEGMENTS);
onComplete({
type: "Polygon",
coordinates: [ring],
});
resetDrawingState();
};
// Kết thúc thao tác kéo bằng mouseup chuột trái.
const onMouseUp = (e: maplibregl.MapMouseEvent) => {
if (getMode() !== "add-circle") return;
if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return;
finishCircle();
};
// Hủy phiên vẽ circle khi nhấn Escape.
const onKeyDown = (e: KeyboardEvent) => {
if (getMode() !== "add-circle") return;
if (e.key !== "Escape") return;
e.preventDefault();
resetDrawingState();
};
map.on("mousedown", onMouseDown);
map.on("mousemove", onMouseMove);
map.on("mouseup", onMouseUp);
document.addEventListener("keydown", onKeyDown);
const cleanup = () => {
map.off("mousedown", onMouseDown);
map.off("mousemove", onMouseMove);
map.off("mouseup", onMouseUp);
document.removeEventListener("keydown", onKeyDown);
resetDrawingState();
if (map.getCanvas().style.cursor === "crosshair") {
map.getCanvas().style.cursor = "";
}
};
return {
cleanup,
cancel: resetDrawingState,
};
}
// Tạo vòng polygon xấp xỉ hình tròn từ tâm, bán kính và số phân đoạn.
function buildCircleRing(
center: [number, number],
radiusMeters: number,
segments: number
): [number, number][] {
const ring: [number, number][] = [];
for (let i = 0; i <= segments; i += 1) {
const bearingDeg = (i / segments) * 360; // Chia đều 360 do quanh tâm để tạo các điểm trên vòng tròn.
ring.push(destinationPoint(center, radiusMeters, bearingDeg));
}
return ring;
}
// Tính khoảng cách hai điểm theo công thức Haversine (đơn vị mét).
function distanceMeters(a: [number, number], b: [number, number]): number {
const lat1 = toRad(a[1]);
const lat2 = toRad(b[1]);
const dLat = lat2 - lat1; // Delta vĩ độ (radian).
const dLng = toRad(b[0] - a[0]); // Delta kinh độ (radian).
const sinLat = Math.sin(dLat / 2); // Thành phần sin(dLat/2) của công thức Haversine.
const sinLng = Math.sin(dLng / 2); // Thành phần sin(dLng/2) của công thức Haversine.
const h =
sinLat * sinLat +
Math.cos(lat1) * Math.cos(lat2) * sinLng * sinLng; // h = haversine(d/R), độ lớn cung tròn chuẩn hóa.
const c = 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)); // Góc tâm (radian) giữa hai điểm trên mặt cầu.
return EARTH_RADIUS_METERS * c; // Khoảng cách cung tròn: d = R * c.
}
// Tính tọa độ điểm đích từ tâm, khoảng cách và góc phương vị.
function destinationPoint(
center: [number, number],
distance: number,
bearingDeg: number
): [number, number] {
const lat1 = toRad(center[1]);
const lng1 = toRad(center[0]);
const bearing = toRad(bearingDeg);
const angularDistance = distance / EARTH_RADIUS_METERS; // d/R: khoảng cách góc trên mặt cầu.
const sinLat1 = Math.sin(lat1);
const cosLat1 = Math.cos(lat1);
const sinAngular = Math.sin(angularDistance);
const cosAngular = Math.cos(angularDistance);
const sinLat2 =
sinLat1 * cosAngular +
cosLat1 * sinAngular * Math.cos(bearing); // Công thức vĩ độ điểm đích theo great-circle.
const lat2 = Math.asin(clamp(sinLat2, -1, 1)); // Kẹp [-1,1] để tránh sai số số học trước khi asin.
const y = Math.sin(bearing) * sinAngular * cosLat1; // Tử số atan2 cho biến thiên kinh độ.
const x = cosAngular - sinLat1 * Math.sin(lat2); // Mẫu số atan2 cho biến thiên kinh độ.
const lng2 = lng1 + Math.atan2(y, x); // Kinh độ đích = kinh độ gốc + delta kinh độ.
return [normalizeLng(toDeg(lng2)), toDeg(lat2)];
}
// Chuẩn hóa kinh độ về miền [-180, 180].
function normalizeLng(lng: number): number {
let normalized = ((lng + 540) % 360) - 180; // Wrap về khoảng [-180, 180).
if (normalized === -180) normalized = 180;
return normalized;
}
// Kẹp giá trị trong đoạn [min, max].
function clamp(value: number, min: number, max: number): number {
if (value < min) return min;
if (value > max) return max;
return value;
}
// Đổi đơn vị góc từ độ sang radian.
function toRad(value: number): number {
return (value * Math.PI) / 180; // Đổi độ sang radian.
}
// Đổi đơn vị góc từ radian sang độ.
function toDeg(value: number): number {
return (value * 180) / Math.PI; // Đổi radian sang độ.
}
+127
View File
@@ -0,0 +1,127 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/useEditorState";
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
// Khởi tạo engine vẽ polygon tự do theo chuỗi click.
export function initDrawing(
map: maplibregl.Map,
getMode: ModeGetter,
onComplete: (geometry: Geometry) => void
) {
let coords: [number, number][] = [];
const clearPreview = () => {
(map.getSource("draw-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
features: [],
});
};
const cancelDrawing = () => {
coords = [];
clearPreview();
};
// Đóng vòng polygon nếu điểm cuối chưa trùng điểm đầu.
function closePolygon(c: [number, number][]) {
if (c.length < 3) return c;
const first = c[0];
const last = c[c.length - 1];
if (first[0] !== last[0] || first[1] !== last[1]) {
return [...c, first];
}
return c;
}
// Cập nhật layer preview trong lúc đang vẽ.
function update(c: [number, number][]) {
const closed = closePolygon(c);
(map.getSource("draw-preview") as maplibregl.GeoJSONSource)?.setData({
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: {},
geometry: {
type: "Polygon",
coordinates: [closed],
},
},
],
});
}
// Ghi nhận đỉnh polygon mới khi click map.
function onClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "draw") return;
coords.push([e.lngLat.lng, e.lngLat.lat] as [number, number]);
update(coords);
}
// Render preview polygon với điểm chuột hiện tại.
function onMove(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "draw" || coords.length === 0) return;
const preview: [number, number][] = [
...coords,
[e.lngLat.lng, e.lngLat.lat] as [number, number],
];
update(preview);
}
// Hoàn tất polygon, trả geometry ra ngoài và reset preview.
function finishDrawing() {
if (getMode() !== "draw" || coords.length < 3) return;
const geometry: Geometry = {
type: "Polygon",
coordinates: [closePolygon(coords)],
};
onComplete(geometry);
cancelDrawing();
}
// Lắng nghe Enter để chốt polygon.
function onKeyDown(e: KeyboardEvent) {
if (getMode() !== "draw") return;
if (e.key === "Enter") {
e.preventDefault();
finishDrawing();
return;
}
if (e.key === "Escape") {
e.preventDefault();
cancelDrawing();
return;
}
if (e.key === "Backspace") {
e.preventDefault();
coords = coords.slice(0, -1);
if (coords.length) {
update(coords);
} else {
clearPreview();
}
}
}
map.on("click", onClick);
map.on("mousemove", onMove);
document.addEventListener("keydown", onKeyDown);
const cleanup = () => {
map.off("click", onClick);
map.off("mousemove", onMove);
document.removeEventListener("keydown", onKeyDown);
cancelDrawing();
};
return {
cleanup,
cancel: cancelDrawing,
};
}
+226
View File
@@ -0,0 +1,226 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/useEditorState";
export type EditingHandle = {
id: string | number;
ring: [number, number][];
original: Geometry;
};
export type EditingAPI = {
beginEditing: (feature: maplibregl.MapGeoJSONFeature) => void;
clearEditing: () => void;
bindEditEvents: (map: maplibregl.Map) => void;
};
// Tạo engine chỉnh sửa polygon đã có (kéo đỉnh, thêm đỉnh, commit/cancel).
export function createEditingEngine(options: {
mapRef: React.MutableRefObject<maplibregl.Map | null>;
onUpdate: (id: string | number, geometry: Geometry) => void;
}) {
const { mapRef, onUpdate } = options;
const editingRef = { current: null as EditingHandle | null };
const dragStateRef = { current: null as { idx: number } | null };
const modifierRef = { current: { ctrl: false, meta: false } };
// Hủy trạng thái chỉnh sửa hiện tại và dọn hai source edit.
const clearEditing = () => {
editingRef.current = null;
dragStateRef.current = null;
const map = mapRef.current;
if (!map) return;
const empty: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: [] };
(map.getSource("edit-shape") as maplibregl.GeoJSONSource | undefined)?.setData(empty);
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(empty);
};
// Đồng bộ polygon tạm và các handle point lên map source.
const updateEditSources = () => {
const editing = editingRef.current;
const map = mapRef.current;
if (!editing || !map) return;
const closedRing = [...editing.ring, editing.ring[0]];
const shape: GeoJSON.FeatureCollection<GeoJSON.Polygon> = {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: { type: "Polygon", coordinates: [closedRing] },
properties: {},
},
],
};
const handles: GeoJSON.FeatureCollection<GeoJSON.Point> = {
type: "FeatureCollection",
features: editing.ring.map((c, idx) => ({
type: "Feature",
geometry: { type: "Point", coordinates: c },
properties: { idx },
})),
};
(map.getSource("edit-shape") as maplibregl.GeoJSONSource | undefined)?.setData(shape);
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(handles);
};
// Chốt chỉnh sửa và emit geometry mới cho caller.
const finishEditing = () => {
const editing = editingRef.current;
if (!editing) return;
const geometry: Geometry = {
type: "Polygon",
coordinates: [[...editing.ring, editing.ring[0]]],
};
onUpdate(editing.id, geometry);
clearEditing();
};
// Thoát chế độ chỉnh sửa mà không lưu thay đổi.
const cancelEditing = () => {
clearEditing();
};
// Bắt đầu chỉnh sửa từ feature polygon được chọn.
const beginEditing = (feature: maplibregl.MapGeoJSONFeature) => {
if (feature.geometry.type !== "Polygon") return;
const coords = (feature.geometry.coordinates?.[0] ?? []) as [number, number][];
if (coords.length < 4) return;
// remove duplicated closing point
const ring = coords.slice(0, -1).map((c) => [c[0], c[1]] as [number, number]);
editingRef.current = {
id: feature.id ?? feature.properties?.id,
ring,
original: feature.geometry as Geometry,
};
updateEditSources();
};
// Kiểm tra trạng thái nhấn phím modifier để bật thao tác chèn đỉnh.
const isModifierPressed = (e?: maplibregl.MapLayerMouseEvent | maplibregl.MapMouseEvent) => {
const oe = e?.originalEvent as MouseEvent | undefined;
return (
modifierRef.current.ctrl ||
modifierRef.current.meta ||
!!oe?.ctrlKey ||
!!oe?.metaKey
);
};
// Gắn toàn bộ sự kiện phục vụ chỉnh sửa hình.
const bindEditEvents = (map: maplibregl.Map) => {
// Bắt đầu kéo một handle point.
const onHandleDown = (e: maplibregl.MapLayerMouseEvent) => {
if (!editingRef.current) return;
const feature = e.features?.[0];
const idx = feature?.properties?.idx;
if (idx === undefined) return;
e.preventDefault();
dragStateRef.current = { idx };
map.getCanvas().style.cursor = "grabbing";
map.dragPan.disable();
};
// Cập nhật vị trí đỉnh trong lúc kéo chuột.
const onHandleMove = (e: maplibregl.MapMouseEvent) => {
const drag = dragStateRef.current;
const editing = editingRef.current;
if (!drag || !editing) return;
editing.ring[drag.idx] = [e.lngLat.lng, e.lngLat.lat];
updateEditSources();
};
// Kết thúc kéo đỉnh và khôi phục trạng thái tương tác map.
const stopDragging = () => {
dragStateRef.current = null;
map.getCanvas().style.cursor = "";
map.dragPan.enable();
};
// Bắt phím điều khiển phiên chỉnh sửa (Enter/Escape + modifier flags).
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Control") {
modifierRef.current.ctrl = true;
} else if (e.key === "Meta") {
modifierRef.current.meta = true;
}
if (!editingRef.current) return;
if (e.key === "Enter") {
finishEditing();
} else if (e.key === "Escape") {
cancelEditing();
}
};
// Hạ cờ modifier khi nhả phím.
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === "Control") {
modifierRef.current.ctrl = false;
} else if (e.key === "Meta") {
modifierRef.current.meta = false;
}
};
// Chèn thêm một đỉnh mới vào ring tại vị trí gần điểm click nhất.
const onInsertHandle = (e: maplibregl.MapLayerMouseEvent) => {
if (!editingRef.current) return;
if (!isModifierPressed(e)) return;
e.preventDefault();
const editing = editingRef.current;
const ring = editing.ring;
const click = [e.lngLat.lng, e.lngLat.lat] as [number, number];
let nearestIdx = 0;
let bestDist = Number.POSITIVE_INFINITY;
ring.forEach((pt, idx) => {
const dx = pt[0] - click[0];
const dy = pt[1] - click[1];
const d = dx * dx + dy * dy; // Dùng khoảng cách Euclid bình phương để so sánh nhanh, không cần sqrt.
if (d < bestDist) {
bestDist = d;
nearestIdx = idx;
}
});
const insertIdx = nearestIdx + 1;
ring.splice(insertIdx, 0, click);
dragStateRef.current = { idx: insertIdx };
map.getCanvas().style.cursor = "grabbing";
map.dragPan.disable();
updateEditSources();
};
// Ngắt kéo nếu con trỏ rời canvas.
const onCanvasLeave = () => {
stopDragging();
};
map.on("mousedown", "edit-handles-circle", onHandleDown);
map.on("mousedown", "edit-shape-line", onInsertHandle);
map.on("mousemove", onHandleMove);
map.on("mouseup", stopDragging);
document.addEventListener("keydown", onKeyDown);
document.addEventListener("keyup", onKeyUp);
map.getCanvas().addEventListener("mouseleave", onCanvasLeave);
map.on("remove", () => {
map.off("mousedown", "edit-handles-circle", onHandleDown);
map.off("mousedown", "edit-shape-line", onInsertHandle);
map.off("mousemove", onHandleMove);
map.off("mouseup", stopDragging);
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("keyup", onKeyUp);
map.getCanvas().removeEventListener("mouseleave", onCanvasLeave);
});
};
return {
beginEditing,
clearEditing,
bindEditEvents,
updateEditSources,
editingRef,
dragStateRef,
};
}
+4
View File
@@ -0,0 +1,4 @@
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
export type ModeGetter = () => EditorMode;
+140
View File
@@ -0,0 +1,140 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/useEditorState";
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
type: "FeatureCollection",
features: [],
};
// Khởi tạo engine vẽ line (gấp khúc, không mũi tên).
export function initLine(
map: maplibregl.Map,
getMode: ModeGetter,
onComplete: (geometry: Geometry) => void
) {
let coords: [number, number][] = [];
// Xóa dữ liệu preview line.
const clearPreview = () => {
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
EMPTY_PREVIEW
);
};
// Hủy phiên vẽ line hiện tại.
const cancelLine = () => {
coords = [];
clearPreview();
};
// Cập nhật line preview theo danh sách tọa độ tạm.
const updatePreview = (lineCoords: [number, number][]) => {
if (lineCoords.length < 2) {
clearPreview();
return;
}
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: {},
geometry: {
type: "LineString",
coordinates: lineCoords,
},
},
],
});
};
// Chốt line khi đủ số đỉnh tối thiểu.
const finishLine = () => {
if (getMode() !== "add-line" || coords.length < 2) return;
const geometry: Geometry = {
type: "LineString",
coordinates: [...coords],
};
onComplete(geometry);
cancelLine();
};
// Xóa đỉnh cuối cùng trong line đang vẽ.
const removeLastVertex = () => {
if (!coords.length) return;
coords = coords.slice(0, -1);
updatePreview(coords);
};
// Thêm một đỉnh line khi click map.
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
if (getMode() !== "add-line") return;
coords.push([e.lngLat.lng, e.lngLat.lat]);
updatePreview(coords);
};
// Cập nhật preview động theo vị trí chuột.
const onMove = (e: maplibregl.MapLayerMouseEvent) => {
const canvas = map.getCanvas();
if (getMode() !== "add-line") {
if (coords.length) {
cancelLine();
}
if (canvas.style.cursor === "crosshair") {
canvas.style.cursor = "";
}
return;
}
canvas.style.cursor = "crosshair";
if (coords.length === 0) return;
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]);
};
// Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ line.
const onKeyDown = (e: KeyboardEvent) => {
if (getMode() !== "add-line") return;
if (e.key === "Enter") {
e.preventDefault();
finishLine();
return;
}
if (e.key === "Escape") {
e.preventDefault();
cancelLine();
return;
}
if (e.key === "Backspace") {
e.preventDefault();
removeLastVertex();
}
};
map.on("click", onClick);
map.on("mousemove", onMove);
document.addEventListener("keydown", onKeyDown);
const cleanup = () => {
map.off("click", onClick);
map.off("mousemove", onMove);
document.removeEventListener("keydown", onKeyDown);
cancelLine();
if (map.getCanvas().style.cursor === "crosshair") {
map.getCanvas().style.cursor = "";
}
};
return {
cleanup,
cancel: cancelLine,
};
}
+142
View File
@@ -0,0 +1,142 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/useEditorState";
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
type: "FeatureCollection",
features: [],
};
// Khởi tạo engine vẽ path (gấp khúc, sẽ render có mũi tên ở layer path).
export function initPath(
map: maplibregl.Map,
getMode: ModeGetter,
onComplete: (geometry: Geometry) => void
) {
let coords: [number, number][] = [];
// Xóa dữ liệu preview path.
const clearPreview = () => {
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
EMPTY_PREVIEW
);
};
// Cập nhật path preview theo danh sách tọa độ tạm.
const updatePreview = (lineCoords: [number, number][]) => {
if (lineCoords.length < 2) {
clearPreview();
return;
}
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: {},
geometry: {
type: "LineString",
coordinates: lineCoords,
},
},
],
});
};
// Chốt path khi đủ số đỉnh tối thiểu.
const finishPath = () => {
if (getMode() !== "add-path" || coords.length < 2) return;
const geometry: Geometry = {
type: "LineString",
coordinates: [...coords],
};
onComplete(geometry);
coords = [];
clearPreview();
};
// Hủy phiên vẽ path hiện tại.
const cancelPath = () => {
coords = [];
clearPreview();
};
// Xóa đỉnh cuối cùng của path đang vẽ.
const removeLastVertex = () => {
if (coords.length === 0) return;
coords = coords.slice(0, -1);
updatePreview(coords);
};
// Thêm một đỉnh path khi click map.
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
if (getMode() !== "add-path") return;
coords.push([e.lngLat.lng, e.lngLat.lat]);
updatePreview(coords);
};
// Cập nhật preview path động theo vị trí chuột.
const onMove = (e: maplibregl.MapLayerMouseEvent) => {
const canvas = map.getCanvas();
if (getMode() !== "add-path") {
if (coords.length) {
cancelPath();
}
if (canvas.style.cursor === "crosshair") {
canvas.style.cursor = "";
}
return;
}
canvas.style.cursor = "crosshair";
if (coords.length === 0) return;
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]);
};
// Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ path.
const onKeyDown = (e: KeyboardEvent) => {
if (getMode() !== "add-path") return;
if (e.key === "Enter") {
e.preventDefault();
finishPath();
return;
}
if (e.key === "Escape") {
e.preventDefault();
cancelPath();
return;
}
if (e.key === "Backspace") {
e.preventDefault();
removeLastVertex();
}
};
map.on("click", onClick);
map.on("mousemove", onMove);
document.addEventListener("keydown", onKeyDown);
const cleanup = () => {
map.off("click", onClick);
map.off("mousemove", onMove);
document.removeEventListener("keydown", onKeyDown);
cancelPath();
if (map.getCanvas().style.cursor === "crosshair") {
map.getCanvas().style.cursor = "";
}
};
return {
cleanup,
cancel: cancelPath,
};
}
+45
View File
@@ -0,0 +1,45 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/useEditorState";
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
// Khởi tạo engine thêm point bằng click đơn.
export function initPoint(
map: maplibregl.Map,
getMode: ModeGetter,
onComplete: (geometry: Geometry) => void
) {
// Thêm point mới khi đang ở chế độ add-point.
function onClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "add-point") return;
const geometry: Geometry = {
type: "Point",
coordinates: [e.lngLat.lng, e.lngLat.lat],
};
onComplete?.(geometry);
}
// Cập nhật trạng thái con trỏ theo mode add-point.
function onMove() {
const canvas = map.getCanvas();
if (getMode() === "add-point") {
canvas.style.cursor = "crosshair";
return;
}
if (canvas.style.cursor === "crosshair") {
canvas.style.cursor = "";
}
}
map.on("click", onClick);
map.on("mousemove", onMove);
return () => {
map.off("click", onClick);
map.off("mousemove", onMove);
if (map.getCanvas().style.cursor === "crosshair") {
map.getCanvas().style.cursor = "";
}
};
}
+258
View File
@@ -0,0 +1,258 @@
import maplibregl from "maplibre-gl";
import type { ModeGetter } from "@/uhm/lib/engine/engineTypes";
// Khởi tạo engine chọn feature và context menu edit/delete.
export function initSelect(
map: maplibregl.Map,
getMode: ModeGetter,
onDelete?: (id: string | number) => void,
onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void,
onSelectId?: (id: string | number | null) => void
) {
const SELECTABLE_LAYERS = [
"countries-fill",
"countries-line",
"routes-line",
"routes-path-arrow-fill",
"routes-path-arrow-line",
"routes-path-hit",
"places-circle",
"places-symbol",
] as const;
const FEATURE_STATE_SOURCES = [
"countries",
"places",
"path-arrow-shapes",
] as const;
const selectedIds = new Set<number | string>();
const hasContextActions = Boolean(onDelete || onEdit);
let contextMenu: HTMLDivElement | null = null;
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
// Bỏ highlight feature-state của toàn bộ đối tượng đang chọn.
function clearSelection(emit = true) {
if (!selectedIds.size) return;
selectedIds.forEach((id) => setSelectionStateForId(id, false));
selectedIds.clear();
if (emit) {
onSelectId?.(null);
}
}
// Chọn hoặc toggle đối tượng; giữ Alt để chọn cộng dồn/tắt chọn.
function selectFeature(feature: maplibregl.MapGeoJSONFeature, additive: boolean) {
const id = feature.id ?? feature.properties?.id;
if (id === undefined || id === null) return;
if (!additive) {
clearSelection();
}
if (additive && selectedIds.has(id)) {
// Alt + click on an already selected feature removes it from the selection
setSelectionStateForId(id, false);
selectedIds.delete(id);
onSelectId?.(selectedIds.size === 1 ? Array.from(selectedIds)[0] : null);
return;
}
setSelectionStateForId(id, true);
selectedIds.add(id);
onSelectId?.(selectedIds.size === 1 ? id : null);
}
// 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: selectableLayers,
}) as maplibregl.MapGeoJSONFeature[];
if (!features.length) {
clearSelection();
return;
}
const additive = !!e.originalEvent?.altKey;
selectFeature(features[0], additive);
}
// Hiển thị menu ngữ cảnh (sửa/xóa) khi click chuột phải.
// 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: selectableLayers,
}) as maplibregl.MapGeoJSONFeature[];
if (!features.length) return;
const feature = features[0];
const id = feature.id ?? feature.properties?.id;
if (id === undefined || id === null) return;
// if right-clicked item not selected, make it the sole selection
if (!selectedIds.has(id)) {
clearSelection();
selectFeature(feature, false);
}
showContextMenu(
e.originalEvent?.clientX ?? e.point.x,
e.originalEvent?.clientY ?? e.point.y,
feature
);
}
// Đổ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: selectableLayers,
});
map.getCanvas().style.cursor = features.length ? "pointer" : "";
}
function getSelectableLayers(): string[] {
return SELECTABLE_LAYERS.filter((layerId) => Boolean(map.getLayer(layerId)));
}
function setSelectionStateForId(id: string | number, selected: boolean) {
for (const source of FEATURE_STATE_SOURCES) {
if (!map.getSource(source)) continue;
map.setFeatureState({ source, id }, { selected });
}
}
map.on("click", onClick);
map.on("mousemove", onMove);
if (hasContextActions) {
map.on("contextmenu", onRightClick);
}
const cleanup = () => {
map.off("click", onClick);
map.off("mousemove", onMove);
if (hasContextActions) {
map.off("contextmenu", onRightClick);
}
clearSelection(false);
hideContextMenu();
};
return {
cleanup,
clearSelection,
};
// Ẩn và dọn dẹp context menu hiện tại.
function hideContextMenu() {
if (contextMenu) {
contextMenu.remove();
contextMenu = null;
}
if (docClickHandler) {
document.removeEventListener("click", docClickHandler);
docClickHandler = null;
}
}
// Render menu ngữ cảnh tối giản gần vị trí con trỏ.
function showContextMenu(
x: number,
y: number,
clickedFeature: maplibregl.MapGeoJSONFeature
) {
hideContextMenu();
const menu = document.createElement("div");
menu.style.position = "fixed";
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
menu.style.background = "#0f172a";
menu.style.color = "white";
menu.style.border = "1px solid #1f2937";
menu.style.borderRadius = "6px";
menu.style.boxShadow = "0 4px 12px rgba(0,0,0,0.2)";
menu.style.zIndex = "9999";
menu.style.minWidth = "120px";
menu.style.fontSize = "14px";
menu.style.padding = "4px 0";
// Tạo một item thao tác trong context menu.
const createItem = (label: string, onClick: () => void) => {
const item = document.createElement("div");
item.textContent = label;
item.style.padding = "8px 12px";
item.style.cursor = "pointer";
item.onmouseenter = () => (item.style.background = "#1f2937");
item.onmouseleave = () => (item.style.background = "transparent");
item.onclick = () => {
onClick();
hideContextMenu();
};
return item;
};
const selectedCount = selectedIds.size || 1;
let hasMenuItems = false;
if (
selectedCount === 1 &&
clickedFeature.source === "countries" &&
clickedFeature.geometry?.type === "Polygon" &&
onEdit
) {
const single = clickedFeature;
menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single)));
hasMenuItems = true;
}
if (onDelete) {
menu.appendChild(
createItem(
selectedCount > 1 ? `Xóa ${selectedCount} mục` : "Xóa",
() => {
const ids = selectedIds.size
? Array.from(selectedIds)
: [clickedFeature.id ?? clickedFeature.properties?.id];
ids.forEach((eachId) => {
if (eachId !== undefined && eachId !== null) onDelete(eachId);
});
clearSelection();
}
)
);
hasMenuItems = true;
}
if (!hasMenuItems) return;
document.body.appendChild(menu);
contextMenu = menu;
// Đóng menu khi click ra ngoài vùng menu.
const onDocClick = (ev: MouseEvent) => {
if (!menu.contains(ev.target as Node)) {
hideContextMenu();
}
};
docClickHandler = onDocClick;
setTimeout(() => document.addEventListener("click", onDocClick), 0);
}
}
+122
View File
@@ -0,0 +1,122 @@
export type EntityTypeGroupId =
| "line"
| "polygon"
| "circle"
| "point";
export type EntityGeometryPreset = "line" | "polygon" | "circle-area" | "point";
export type EntityTypeGroup = {
id: EntityTypeGroupId;
label: string;
geometryLabel: string;
description: string;
};
export type EntityTypeOption = {
value: string;
label: string;
groupId: EntityTypeGroupId;
groupLabel: string;
geometryPreset: EntityGeometryPreset;
};
export const ENTITY_TYPE_GROUPS: EntityTypeGroup[] = [
{
id: "line",
label: "line - Tuyến",
geometryLabel: "Line",
description: "Các tuyến line/path (gấp khúc).",
},
{
id: "polygon",
label: "polygon - Đa giác",
geometryLabel: "Polygon",
description: "Vùng lãnh thổ dạng đa giác.",
},
{
id: "circle",
label: "circle - Tròn",
geometryLabel: "Circle",
description: "Vùng sự kiện theo bán kính ảnh hưởng.",
},
{
id: "point",
label: "point - Điểm",
geometryLabel: "Point",
description: "Địa điểm đơn lẻ.",
},
];
const GROUP_BY_ID: Record<EntityTypeGroupId, EntityTypeGroup> = {
line: ENTITY_TYPE_GROUPS[0],
polygon: ENTITY_TYPE_GROUPS[1],
circle: ENTITY_TYPE_GROUPS[2],
point: ENTITY_TYPE_GROUPS[3],
};
const RAW_ENTITY_TYPE_OPTIONS: Array<{
value: string;
label: string;
groupId: EntityTypeGroupId;
geometryPreset: EntityGeometryPreset;
}> = [
{ value: "defense_line", label: "Defense Line", groupId: "line", geometryPreset: "line" },
{ value: "attack_route", label: "Attack Route", groupId: "line", geometryPreset: "line" },
{ value: "retreat_route", label: "Retreat Route", groupId: "line", geometryPreset: "line" },
{ value: "invasion_route", label: "Invasion Route", groupId: "line", geometryPreset: "line" },
{ value: "migration_route", label: "Migration Route", groupId: "line", geometryPreset: "line" },
{ value: "refugee_route", label: "Refugee Route", groupId: "line", geometryPreset: "line" },
{ value: "trade_route", label: "Trade Route", groupId: "line", geometryPreset: "line" },
{ value: "shipping_route", label: "Shipping Route", groupId: "line", geometryPreset: "line" },
{ value: "country", label: "Country", groupId: "polygon", geometryPreset: "polygon" },
{ value: "state", label: "State", groupId: "polygon", geometryPreset: "polygon" },
{ value: "empire", label: "Empire", groupId: "polygon", geometryPreset: "polygon" },
{ value: "kingdom", label: "Kingdom", groupId: "polygon", geometryPreset: "polygon" },
{ value: "war", label: "War", groupId: "circle", geometryPreset: "circle-area" },
{ value: "battle", label: "Battle", groupId: "circle", geometryPreset: "circle-area" },
{ value: "civilization", label: "Civilization", groupId: "circle", geometryPreset: "circle-area" },
{ value: "rebellion_zone", label: "Rebellion Zone", groupId: "circle", geometryPreset: "circle-area" },
{ value: "person_deathplace", label: "Person Deathplace", groupId: "point", geometryPreset: "point" },
{ value: "person_birthplace", label: "Person Birthplace", groupId: "point", geometryPreset: "point" },
{ value: "person_activity", label: "Person Activity", groupId: "point", geometryPreset: "point" },
{ value: "temple", label: "Temple", groupId: "point", geometryPreset: "point" },
{ value: "capital", label: "Capital", groupId: "point", geometryPreset: "point" },
{ value: "city", label: "City", groupId: "point", geometryPreset: "point" },
{ value: "fortress", label: "Fortress", groupId: "point", geometryPreset: "point" },
{ value: "castle", label: "Castle", groupId: "point", geometryPreset: "point" },
{ value: "ruin", label: "Ruin", groupId: "point", geometryPreset: "point" },
{ value: "port", label: "Port", groupId: "point", geometryPreset: "point" },
{ value: "bridge", label: "Bridge", groupId: "point", geometryPreset: "point" },
];
export const ENTITY_TYPE_OPTIONS: EntityTypeOption[] = RAW_ENTITY_TYPE_OPTIONS.map((item) => ({
...item,
groupLabel: GROUP_BY_ID[item.groupId].label,
}));
export const DEFAULT_ENTITY_TYPE_ID = "country";
// Gom option theo group để render select phân nhóm.
export function groupEntityTypeOptions(options: EntityTypeOption[] = ENTITY_TYPE_OPTIONS): Array<{
id: EntityTypeGroupId;
label: string;
geometryLabel: string;
description: string;
options: EntityTypeOption[];
}> {
return ENTITY_TYPE_GROUPS.map((group) => ({
...group,
options: options.filter((option) => option.groupId === group.id),
})).filter((group) => group.options.length > 0);
}
// Tìm option theo type id, trả null nếu không tồn tại.
export function findEntityTypeOption(typeId: string | null | undefined): EntityTypeOption | null {
if (!typeId) return null;
return ENTITY_TYPE_OPTIONS.find((option) => option.value === typeId) || null;
}
+14
View File
@@ -0,0 +1,14 @@
import type { FeatureCollection } from "@/uhm/types/geo";
export const WORLD_BBOX = {
minLng: -180,
minLat: -90,
maxLng: 180,
maxLat: 90,
} as const;
export const EMPTY_FEATURE_COLLECTION: FeatureCollection = {
type: "FeatureCollection",
features: [],
};
+13
View File
@@ -0,0 +1,13 @@
export const DEFAULT_POINT_ICON_ID = "point-icon-default";
export const POINT_ICON_URL = "/point.png";
export const PATH_ARROW_ICON_ID = "path-arrow-icon";
export const MAP_MIN_ZOOM = 1;
export const MAP_MAX_ZOOM = 10;
export const RASTER_BASE_SOURCE_ID = "rasterBase";
export const RASTER_BASE_LAYER_ID = "raster-base-layer";
export const RASTER_BASE_INSERT_BEFORE_LAYER_ID = "graticules-line";
export const PATH_ARROW_SOURCE_ID = "path-arrow-shapes";
export const FEATURE_STATE_SOURCE_IDS = ["countries", "places", PATH_ARROW_SOURCE_ID] as const;
+76
View File
@@ -0,0 +1,76 @@
import maplibregl from "maplibre-gl";
export const COUNTRY_COLOR_KEY_EXPRESSION: maplibregl.ExpressionSpecification = [
"coalesce",
["get", "MAPCOLOR7"],
["get", "MAPCOLOR9"],
["get", "scalerank"],
0,
];
export const COUNTRY_FILL_COLOR_EXPRESSION: maplibregl.ExpressionSpecification = [
"match",
COUNTRY_COLOR_KEY_EXPRESSION,
1, "#ef4444",
2, "#f97316",
3, "#f59e0b",
4, "#22c55e",
5, "#06b6d4",
6, "#3b82f6",
7, "#8b5cf6",
8, "#a855f7",
9, "#d946ef",
10, "#14b8a6",
"#64748b",
];
export const POLYGON_FILL_BY_TYPE: Record<string, string> = {
country: "#2563eb",
state: "#0ea5e9",
empire: "#f59e0b",
kingdom: "#d97706",
war: "#dc2626",
battle: "#f43f5e",
civilization: "#14b8a6",
rebellion_zone: "#7c3aed",
};
export const POLYGON_STROKE_BY_TYPE: Record<string, string> = {
country: "#1e3a8a",
state: "#0c4a6e",
empire: "#7c2d12",
kingdom: "#9a3412",
war: "#7f1d1d",
battle: "#9f1239",
civilization: "#134e4a",
rebellion_zone: "#4c1d95",
};
export const POLYGON_OPACITY_BY_TYPE: Record<string, number> = {
war: 0.3,
battle: 0.34,
civilization: 0.38,
rebellion_zone: 0.32,
};
export const LINE_COLOR_BY_TYPE: Record<string, string> = {
defense_line: "#f97316",
attack_route: "#ef4444",
retreat_route: "#94a3b8",
invasion_route: "#b91c1c",
migration_route: "#0ea5e9",
refugee_route: "#06b6d4",
trade_route: "#eab308",
shipping_route: "#2563eb",
};
export const PATH_RENDER_BY_TYPE: Record<string, boolean> = {
attack_route: true,
retreat_route: true,
invasion_route: true,
migration_route: true,
refugee_route: true,
trade_route: true,
shipping_route: true,
};
+25
View File
@@ -0,0 +1,25 @@
import type { TimelineRange } from "@/uhm/lib/editor/session/sessionTypes";
// Single source of truth for the app-wide timeline range.
export const FIXED_TIMELINE_START_YEAR = -2000;
export const FIXED_TIMELINE_END_YEAR = 2000;
export const FIXED_TIMELINE_RANGE: TimelineRange = {
min: FIXED_TIMELINE_START_YEAR,
max: FIXED_TIMELINE_END_YEAR,
};
// UI debounce when user drags timeline before triggering data fetch.
export const TIMELINE_DEBOUNCE_MS = 180;
export function clampYearValue(year: number, minYear: number, maxYear: number): number {
const lower = Math.min(minYear, maxYear);
const upper = Math.max(minYear, maxYear);
if (year < lower) return lower;
if (year > upper) return upper;
return year;
}
export function clampYearToFixedRange(year: number): number {
return clampYearValue(year, FIXED_TIMELINE_START_YEAR, FIXED_TIMELINE_END_YEAR);
}
+51
View File
@@ -0,0 +1,51 @@
import { useState } from "react";
import type { FeatureCollection } from "@/uhm/types/geo";
import { useBackgroundSessionState } from "@/uhm/lib/editor/session/useBackgroundSessionState";
import { useEntitySessionState } from "@/uhm/lib/editor/session/useEntitySessionState";
import { useSectionSessionState } from "@/uhm/lib/editor/session/useSectionSessionState";
import { useTimelineState } from "@/uhm/lib/editor/session/useTimelineState";
import type { EditorMode, TimelineRange } from "@/uhm/lib/editor/session/sessionTypes";
export type {
CreatedEntitySummary,
EditorMode,
EntityFormState,
GeometryMetaFormState,
PendingEntityCreate,
TimelineRange,
} from "@/uhm/lib/editor/session/sessionTypes";
type Options = {
emptyFeatureCollection: FeatureCollection;
defaultEditorUserId: string;
fallbackTimelineRange: TimelineRange;
currentYear: number;
};
export function useEditorSessionState(options: Options) {
// Mode thao tác map/editor hiện tại.
const [mode, setMode] = useState<EditorMode>("idle");
// FeatureCollection "gốc" của session hiện tại (global timeline hoặc section snapshot).
const [initialData, setInitialData] = useState<FeatureCollection>(options.emptyFeatureCollection);
const section = useSectionSessionState({
defaultEditorUserId: options.defaultEditorUserId,
});
const entity = useEntitySessionState();
const timeline = useTimelineState({
currentYear: options.currentYear,
fallbackTimelineRange: options.fallbackTimelineRange,
});
const background = useBackgroundSessionState();
return {
mode,
setMode,
initialData,
setInitialData,
...section,
...entity,
...timeline,
...background,
};
}
+186
View File
@@ -0,0 +1,186 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
Feature,
FeatureCollection,
FeatureProperties,
Geometry,
} from "@/uhm/types/geo";
import { buildInitialMap, deepClone, diffDraftToInitial } from "@/uhm/lib/editor/draft/draftDiff";
import { useDraftState } from "@/uhm/lib/editor/draft/useDraftState";
import { useUndoStack } from "@/uhm/lib/editor/draft/useUndoStack";
import type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
export type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo";
export type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
// State trung tâm của editor:
// - draft: dữ liệu nguồn để render UI
// - changes: map các thay đổi chờ lưu
// - undoStack: lịch sử thao tác tối thiểu để hoàn tác
export function useEditorState(initialData: FeatureCollection) {
const { draft, draftRef, commitDraft, resetDraft } = useDraftState(initialData);
// Map baseline (id -> feature) để diff draft hiện tại ra changes.
const initialMapRef = useRef<Map<FeatureProperties["id"], Feature>>(
buildInitialMap(initialData)
);
// Version counter để ép diff recalculation sau khi reset/clear baseline.
const [baselineVersion, setBaselineVersion] = useState(0);
const applyUndoAction = useCallback((action: UndoAction): boolean => {
switch (action.type) {
case "create": {
commitDraft({
...draftRef.current,
features: draftRef.current.features.filter((feature) =>
feature.properties.id !== action.id
),
});
return true;
}
case "delete": {
const feature = deepClone(action.feature);
commitDraft({
...draftRef.current,
features: [...draftRef.current.features, feature],
});
return true;
}
case "update": {
const idx = draftRef.current.features.findIndex((feature) =>
feature.properties.id === action.id
);
if (idx === -1) return false;
const nextFeatures = [...draftRef.current.features];
nextFeatures[idx] = {
...nextFeatures[idx],
geometry: deepClone(action.prevGeometry),
};
commitDraft({ ...draftRef.current, features: nextFeatures });
return true;
}
case "properties": {
const idx = draftRef.current.features.findIndex((feature) =>
feature.properties.id === action.id
);
if (idx === -1) return false;
const nextFeatures = [...draftRef.current.features];
nextFeatures[idx] = {
...nextFeatures[idx],
properties: deepClone(action.prevProperties),
};
commitDraft({ ...draftRef.current, features: nextFeatures });
return true;
}
default:
return false;
}
}, [commitDraft, draftRef]);
const { undoStack, pushUndo, undo, clearUndo } = useUndoStack({ applyUndoAction });
useEffect(() => {
resetDraft(deepClone(initialData));
clearUndo();
initialMapRef.current = buildInitialMap(initialData);
setBaselineVersion((version) => version + 1);
}, [clearUndo, initialData, resetDraft]);
const changes = useMemo(() => {
const baseline = initialMapRef.current;
return diffDraftToInitial(draft, baseline);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draft, baselineVersion]);
const changeCount = useMemo(() => changes.size, [changes]);
function createFeature(feature: Feature) {
const featureClone = deepClone(feature);
commitDraft({
...draftRef.current,
features: [...draftRef.current.features, featureClone],
});
pushUndo({ type: "create", id: featureClone.properties.id });
}
function patchFeatureProperties(
id: FeatureProperties["id"],
patch: Partial<FeatureProperties>
) {
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id);
if (idx === -1) return;
const nextFeatures = [...draftRef.current.features];
const prevProperties = deepClone(nextFeatures[idx].properties);
nextFeatures[idx] = {
...nextFeatures[idx],
properties: {
...nextFeatures[idx].properties,
...deepClone(patch),
},
};
if (JSON.stringify(prevProperties) === JSON.stringify(nextFeatures[idx].properties)) {
return;
}
pushUndo({ type: "properties", id, prevProperties });
commitDraft({ ...draftRef.current, features: nextFeatures });
}
function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) {
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id);
if (idx === -1) return;
const prevFeature = draftRef.current.features[idx];
const prevGeometry = deepClone(prevFeature.geometry);
const nextFeatures = [...draftRef.current.features];
nextFeatures[idx] = {
...prevFeature,
geometry: deepClone(newGeometry),
};
pushUndo({ type: "update", id, prevGeometry });
commitDraft({ ...draftRef.current, features: nextFeatures });
}
function deleteFeature(id: FeatureProperties["id"]) {
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id);
if (idx === -1) return;
const feature = draftRef.current.features[idx];
const nextFeatures = [...draftRef.current.features];
nextFeatures.splice(idx, 1);
pushUndo({ type: "delete", feature: deepClone(feature) });
commitDraft({ ...draftRef.current, features: nextFeatures });
}
function buildPayload(): Change[] {
return Array.from(changes.values()).map((change) => deepClone(change));
}
function clearChanges() {
clearUndo();
initialMapRef.current = buildInitialMap(draftRef.current);
setBaselineVersion((version) => version + 1);
}
function hasPersistedFeature(id: FeatureProperties["id"]) {
return initialMapRef.current.has(id);
}
return {
draft,
changes,
undoStack,
changeCount,
createFeature,
patchFeatureProperties,
updateFeature,
deleteFeature,
undo,
buildPayload,
clearChanges,
hasPersistedFeature,
};
}
+18
View File
@@ -0,0 +1,18 @@
export type ApiEnvelope<T> = {
// API cũ: "success" | "error"
// API mới: boolean (true/false)
status: boolean | "success" | "error" | string;
data?: T;
message?: string;
errors?: unknown;
pagination?: unknown;
};
export type GeometriesBBoxQuery = {
minLng: number;
minLat: number;
maxLng: number;
maxLat: number;
time?: number;
entity_id?: string;
};
+31
View File
@@ -0,0 +1,31 @@
export type Entity = {
id: string;
name: string;
// API mới
description?: string | null;
thumbnail_url?: string | null;
is_deleted?: boolean;
created_at?: string;
updated_at?: string;
// API cũ / snapshot editor (giữ optional để không phá flow editor snapshot)
slug?: string | null;
type_id?: string | null;
status?: number | null;
geometry_count?: number;
};
export type EntitySnapshotOperation = "create" | "update" | "delete" | "reference" | "replace";
export type EntitySnapshot = {
id: string;
operation: EntitySnapshotOperation;
name?: string;
slug?: string | null;
description?: string | null;
type_id?: string | null;
status?: number | null;
is_deleted?: number;
base_updated_at?: string;
base_hash?: string;
};
+70
View File
@@ -0,0 +1,70 @@
import type { EntityGeometryPreset } from "@/uhm/lib/entityTypeOptions";
export type Geometry =
| { type: "Point"; coordinates: [number, number] }
| { type: "MultiPoint"; coordinates: [number, number][] }
| { type: "LineString"; coordinates: [number, number][] }
| { type: "MultiLineString"; coordinates: [number, number][][] }
| { type: "Polygon"; coordinates: [number, number][][] }
| { type: "MultiPolygon"; coordinates: [number, number][][][] };
export type FeatureId = string | number;
export type FeatureProperties = {
id: FeatureId;
type?: string | null;
geometry_preset?: EntityGeometryPreset | null;
time_start?: number | null;
time_end?: number | null;
binding?: string[];
entity_id?: string | null;
entity_ids?: string[];
entity_name?: string | null;
entity_names?: string[];
entity_type_id?: string | null;
};
export type Feature = {
type: "Feature";
properties: FeatureProperties;
geometry: Geometry;
};
export type FeatureCollection = {
type: "FeatureCollection";
features: Feature[];
};
export type GeometrySnapshotOperation = "create" | "update" | "delete" | "reference" | "replace";
export type GeometrySnapshot = {
id: string;
operation: GeometrySnapshotOperation;
type?: string | null;
draw_geometry?: Geometry;
geometry?: Geometry;
binding?: string[];
time_start?: number | null;
time_end?: number | null;
bbox?: {
min_lng: number;
min_lat: number;
max_lng: number;
max_lat: number;
} | null;
is_deleted?: number;
base_updated_at?: string;
base_hash?: string;
};
export type LinkScopeSnapshot = {
geometry_id: string;
operation: "replace" | "reference";
entity_ids: string[];
base_links_hash?: string;
};
export type GeometryChange =
| { action: "create"; feature: Feature }
| { action: "update"; id: FeatureId; geometry: Geometry }
| { action: "delete"; id: FeatureId };
+87
View File
@@ -0,0 +1,87 @@
import type { EntitySnapshot } from "@/uhm/types/entities";
import type { FeatureCollection, GeometrySnapshot, LinkScopeSnapshot } from "@/uhm/types/geo";
// API mới (BackEndGo) dùng Projects/Commits/Submissions.
// Giữ tên type "Section" để tránh thay đổi lan rộng trong FE hiện tại.
export type SectionStatus = string;
export type SectionSubmissionStatus = "PENDING" | "APPROVED" | "REJECTED" | string;
export type SectionState = {
// Derived state from ProjectResponse (not persisted as-is in API mới).
status: SectionStatus;
head_commit_id: string | null;
locked_by?: string | null;
};
export type Section = {
id: string;
title: string;
description: string | null;
project_status?: string;
latest_commit_id?: string | null;
submission_ids?: string[];
locked_by?: string | null;
user_id?: string;
created_at?: string;
updated_at?: string;
state?: {
status?: string;
};
};
export type SectionCommit = {
id: string;
project_id: string;
snapshot_json: EditorSnapshot;
snapshot_hash: string;
user_id: string;
edit_summary: string;
created_at?: string;
};
export type SectionSubmission = {
id: string;
project_id: string;
commit_id: string;
user_id: string;
created_at?: string;
status: SectionSubmissionStatus;
reviewed_by?: string | null;
reviewed_at?: string | null;
review_note?: string | null;
content?: string | null;
};
export type EditorSnapshot = {
schema_version: number;
section: {
id: string;
title: string;
};
editor_feature_collection?: FeatureCollection;
entities?: EntitySnapshot[];
geometries?: GeometrySnapshot[];
link_scopes?: LinkScopeSnapshot[];
};
export type EditorLoadResponse = {
section: Section;
state: SectionState;
commit: SectionCommit | null;
snapshot: EditorSnapshot | null;
};
export type CreateSectionInput = {
title: string;
description?: string | null;
status?: "PRIVATE" | "PUBLIC" | "ARCHIVE";
};
export type CreateCommitInput = {
snapshot: EditorSnapshot;
edit_summary: string;
};
export type RestoreCommitInput = {
commit_id: string;
};