tons of feature
This commit is contained in:
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Ignored default folder with query files
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/ultimate-history-map.iml" filepath="$PROJECT_DIR$/.idea/ultimate-history-map.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/ultimate-history-map.iml
generated
Normal file
8
.idea/ultimate-history-map.iml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
36
README.md
Normal file
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
26
app/globals.css
Normal file
26
app/globals.css
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #171717;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--foreground: #ededed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
34
app/layout.tsx
Normal file
34
app/layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Create Next App",
|
||||||
|
description: "Generated by create next app",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
app/page.tsx
Normal file
86
app/page.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Map from "@/components/Map";
|
||||||
|
import Editor from "@/components/Editor";
|
||||||
|
import {
|
||||||
|
FeatureCollection,
|
||||||
|
useEditorState,
|
||||||
|
} from "@/lib/useEditorState";
|
||||||
|
|
||||||
|
const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point">("idle");
|
||||||
|
const [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FC);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const editor = useEditorState(initialData);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadInitial() {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
minLng: "-180",
|
||||||
|
minLat: "-90",
|
||||||
|
maxLng: "180",
|
||||||
|
maxLat: "90",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`http://localhost:3000/geometries?${params.toString()}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
setInitialData(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Load initial data failed", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadInitial();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const payload = editor.buildPayload();
|
||||||
|
if (!payload.length) return;
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("http://localhost:3000/geometries/batch", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ changes: payload }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
console.error("Save failed", text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.clearChanges();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Save error", err);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex" }}>
|
||||||
|
<Editor
|
||||||
|
mode={mode}
|
||||||
|
setMode={setMode}
|
||||||
|
onUndo={editor.undo}
|
||||||
|
onSave={handleSave}
|
||||||
|
isSaving={isSaving}
|
||||||
|
changesCount={editor.changeCount}
|
||||||
|
undoStack={editor.undoStack}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Map
|
||||||
|
mode={mode}
|
||||||
|
draft={editor.draft}
|
||||||
|
onCreateFeature={editor.createFeature}
|
||||||
|
onDeleteFeature={editor.deleteFeature}
|
||||||
|
onUpdateFeature={editor.updateFeature}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
components/Editor.tsx
Normal file
174
components/Editor.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { UndoAction } from "@/lib/useEditorState";
|
||||||
|
|
||||||
|
type Mode = "draw" | "select" | "idle" | "add-point";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mode: Mode;
|
||||||
|
setMode: (mode: Mode) => void;
|
||||||
|
onUndo: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
isSaving: boolean;
|
||||||
|
changesCount: number;
|
||||||
|
undoStack: UndoAction[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Editor({
|
||||||
|
mode,
|
||||||
|
setMode,
|
||||||
|
onUndo,
|
||||||
|
onSave,
|
||||||
|
isSaving,
|
||||||
|
changesCount,
|
||||||
|
undoStack,
|
||||||
|
}: Props) {
|
||||||
|
const toggleMode = (newMode: Mode) => {
|
||||||
|
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: Mode) => ({
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px",
|
||||||
|
marginBottom: "6px",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: mode === btnMode ? "#4caf50" : "#222",
|
||||||
|
color: "white",
|
||||||
|
borderRadius: "4px",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "220px",
|
||||||
|
background: "#111",
|
||||||
|
color: "white",
|
||||||
|
padding: "12px",
|
||||||
|
borderRight: "1px solid #333",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ marginBottom: "10px" }}>Editor</h3>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div style={{ marginTop: "12px", fontSize: "14px" }}>
|
||||||
|
Mode: <b>{mode}</b>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: "12px", display: "flex", gap: "8px" }}>
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "#334155",
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
onClick={onUndo}
|
||||||
|
>
|
||||||
|
Undo
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "none",
|
||||||
|
cursor: isSaving ? "not-allowed" : "pointer",
|
||||||
|
background: isSaving ? "#555" : "#3b82f6",
|
||||||
|
color: "white",
|
||||||
|
opacity: changesCount === 0 ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={isSaving || changesCount === 0}
|
||||||
|
>
|
||||||
|
Save ({changesCount})
|
||||||
|
</button>
|
||||||
|
</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ụ có thể undo ({recentUndoLabels.length})
|
||||||
|
</div>
|
||||||
|
{recentUndoLabels.length === 0 ? (
|
||||||
|
<div style={{ color: "#94a3b8", fontSize: "13px" }}>Chưa có 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
default:
|
||||||
|
return "Tác vụ";
|
||||||
|
}
|
||||||
|
}
|
||||||
328
components/Map.tsx
Normal file
328
components/Map.tsx
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
|
|
||||||
|
import { initDrawing } from "@/lib/drawingEngine";
|
||||||
|
import { initSelect } from "@/lib/selectingEngine";
|
||||||
|
import { initPoint } from "@/lib/pointEngine";
|
||||||
|
import { createEditingEngine } from "@/lib/editingEngine";
|
||||||
|
import { FeatureCollection, Geometry } from "@/lib/useEditorState";
|
||||||
|
|
||||||
|
type MapProps = {
|
||||||
|
mode: "idle" | "draw" | "select" | "add-point";
|
||||||
|
draft: FeatureCollection;
|
||||||
|
onCreateFeature: (feature: FeatureCollection["features"][number]) => void;
|
||||||
|
onDeleteFeature: (id: string | number) => void;
|
||||||
|
onUpdateFeature: (id: string | number, geometry: Geometry) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Map({ mode, draft, onCreateFeature, onDeleteFeature, onUpdateFeature }: MapProps) {
|
||||||
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
|
const modeRef = useRef<MapProps["mode"]>(mode);
|
||||||
|
const draftRef = useRef<FeatureCollection>(draft);
|
||||||
|
const onCreateRef = useRef(onCreateFeature);
|
||||||
|
const onDeleteRef = useRef(onDeleteFeature);
|
||||||
|
const onUpdateRef = useRef(onUpdateFeature);
|
||||||
|
|
||||||
|
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
modeRef.current = mode;
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
draftRef.current = draft;
|
||||||
|
}, [draft]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onCreateRef.current = onCreateFeature;
|
||||||
|
}, [onCreateFeature]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onDeleteRef.current = onDeleteFeature;
|
||||||
|
}, [onDeleteFeature]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onUpdateRef.current = onUpdateFeature;
|
||||||
|
}, [onUpdateFeature]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editingEngineRef.current) {
|
||||||
|
editingEngineRef.current = createEditingEngine({
|
||||||
|
mapRef,
|
||||||
|
onUpdate: (id, geometry) => onUpdateRef.current?.(id, geometry),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push given draft into map sources (idempotent).
|
||||||
|
* Always clear feature-state to avoid stale selection overlays after undo/replace.
|
||||||
|
*/
|
||||||
|
const applyDraftToMap = useCallback((fc: FeatureCollection) => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const countriesSource = map.getSource("countries") as maplibregl.GeoJSONSource | undefined;
|
||||||
|
const placesSource = map.getSource("places") as maplibregl.GeoJSONSource | undefined;
|
||||||
|
|
||||||
|
if (!countriesSource || !placesSource) return;
|
||||||
|
|
||||||
|
// clear all feature-state (selection) to prevent ghost layers after undo
|
||||||
|
map.removeFeatureState({ source: "countries" });
|
||||||
|
|
||||||
|
const { polygons, points } = splitDraftFeatures(fc);
|
||||||
|
|
||||||
|
countriesSource.setData(polygons);
|
||||||
|
placesSource.setData(points);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = new maplibregl.Map({
|
||||||
|
container: "map",
|
||||||
|
attributionControl: false,
|
||||||
|
style: {
|
||||||
|
version: 8,
|
||||||
|
sources: {
|
||||||
|
base: {
|
||||||
|
type: "vector",
|
||||||
|
tiles: ["http://localhost:3000/tiles/{z}/{x}/{y}"],
|
||||||
|
minzoom: 0,
|
||||||
|
maxzoom: 6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: "background",
|
||||||
|
type: "background",
|
||||||
|
paint: {
|
||||||
|
"background-color": "#0b1220",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "land",
|
||||||
|
type: "fill",
|
||||||
|
source: "base",
|
||||||
|
"source-layer": "land",
|
||||||
|
paint: {
|
||||||
|
"fill-color": "#1e293b",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
center: [0, 20],
|
||||||
|
zoom: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
mapRef.current = map;
|
||||||
|
|
||||||
|
map.on("load", async () => {
|
||||||
|
// preview (drawing)
|
||||||
|
map.addSource("draw-preview", {
|
||||||
|
type: "geojson",
|
||||||
|
data: {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: "draw-preview-fill",
|
||||||
|
type: "fill",
|
||||||
|
source: "draw-preview",
|
||||||
|
paint: {
|
||||||
|
"fill-color": "#22c55e",
|
||||||
|
"fill-opacity": 0.4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: "draw-preview-line",
|
||||||
|
type: "line",
|
||||||
|
source: "draw-preview",
|
||||||
|
paint: {
|
||||||
|
"line-color": "#16a34a",
|
||||||
|
"line-width": 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// data thật
|
||||||
|
map.addSource("countries", {
|
||||||
|
type: "geojson",
|
||||||
|
data: {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
},
|
||||||
|
promoteId: "id",
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: "countries-fill",
|
||||||
|
type: "fill",
|
||||||
|
source: "countries",
|
||||||
|
paint: {
|
||||||
|
"fill-color": [
|
||||||
|
"case",
|
||||||
|
["boolean", ["feature-state", "selected"], false],
|
||||||
|
"#22c55e", // selected
|
||||||
|
"#f59e0b", // normal
|
||||||
|
],
|
||||||
|
"fill-opacity": 0.5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: "countries-line",
|
||||||
|
type: "line",
|
||||||
|
source: "countries",
|
||||||
|
paint: {
|
||||||
|
"line-color": "#fbbf24",
|
||||||
|
"line-width": 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addSource("places", {
|
||||||
|
type: "geojson",
|
||||||
|
data: {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// editing overlays
|
||||||
|
map.addSource("edit-shape", {
|
||||||
|
type: "geojson",
|
||||||
|
data: { type: "FeatureCollection", features: [] },
|
||||||
|
});
|
||||||
|
map.addSource("edit-handles", {
|
||||||
|
type: "geojson",
|
||||||
|
data: { type: "FeatureCollection", features: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: "edit-shape-line",
|
||||||
|
type: "line",
|
||||||
|
source: "edit-shape",
|
||||||
|
paint: {
|
||||||
|
"line-color": "#38bdf8",
|
||||||
|
"line-width": 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: "edit-handles-circle",
|
||||||
|
type: "circle",
|
||||||
|
source: "edit-handles",
|
||||||
|
paint: {
|
||||||
|
"circle-color": "#f97316",
|
||||||
|
"circle-radius": 12,
|
||||||
|
"circle-stroke-color": "#0f172a",
|
||||||
|
"circle-stroke-width": 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// load icon từ /public
|
||||||
|
map.loadImage("/point.png", (err, image) => {
|
||||||
|
if (err) throw err;
|
||||||
|
|
||||||
|
if (!map.hasImage("point-icon")) {
|
||||||
|
map.addImage("point-icon", image);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// layer
|
||||||
|
map.addLayer({
|
||||||
|
id: "places-symbol",
|
||||||
|
type: "symbol",
|
||||||
|
source: "places",
|
||||||
|
layout: {
|
||||||
|
"icon-image": "point-icon",
|
||||||
|
"icon-size": 0.5,
|
||||||
|
"icon-anchor": "bottom",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// init drawing
|
||||||
|
const cleanup = initDrawing(
|
||||||
|
map,
|
||||||
|
() => modeRef.current,
|
||||||
|
(geometry: Geometry) => {
|
||||||
|
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
||||||
|
onCreateRef.current({
|
||||||
|
type: "Feature",
|
||||||
|
properties: { id, kind: "country" },
|
||||||
|
geometry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const cleanupSelect = initSelect(
|
||||||
|
map,
|
||||||
|
() => modeRef.current,
|
||||||
|
(id: string | number) => {
|
||||||
|
// ensure edit overlays are cleared when a feature gets removed
|
||||||
|
editingEngineRef.current?.clearEditing();
|
||||||
|
onDeleteRef.current(id);
|
||||||
|
},
|
||||||
|
(feature) => editingEngineRef.current?.beginEditing(feature)
|
||||||
|
);
|
||||||
|
|
||||||
|
const cleanupPoint = initPoint(
|
||||||
|
map,
|
||||||
|
() => modeRef.current,
|
||||||
|
(geometry: Geometry) => {
|
||||||
|
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
|
||||||
|
onCreateRef.current({
|
||||||
|
type: "Feature",
|
||||||
|
properties: { id, kind: "place" },
|
||||||
|
geometry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
map.on("remove", cleanupPoint);
|
||||||
|
|
||||||
|
map.on("remove", cleanupSelect);
|
||||||
|
|
||||||
|
map.on("remove", cleanup);
|
||||||
|
|
||||||
|
// after everything mounted, push current draft to sources
|
||||||
|
applyDraftToMap(draftRef.current);
|
||||||
|
|
||||||
|
editingEngineRef.current?.bindEditEvents(map);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => map.remove();
|
||||||
|
}, [applyDraftToMap]);
|
||||||
|
|
||||||
|
// sync draft -> map sources and drop edit overlays if feature vanished
|
||||||
|
useEffect(() => {
|
||||||
|
applyDraftToMap(draft);
|
||||||
|
const editingId = editingEngineRef.current?.editingRef.current?.id;
|
||||||
|
if (editingId !== undefined && editingId !== null) {
|
||||||
|
const stillExists = draft.features.some((f) => f.properties.id === editingId);
|
||||||
|
if (!stillExists) {
|
||||||
|
editingEngineRef.current?.clearEditing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [draft, applyDraftToMap]);
|
||||||
|
|
||||||
|
return <div id="map" style={{ flex: 1, height: "100vh" }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitDraftFeatures(fc: FeatureCollection) {
|
||||||
|
const polygons = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: fc.features.filter((f) => f.geometry.type !== "Point"),
|
||||||
|
} as FeatureCollection;
|
||||||
|
|
||||||
|
const points = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: fc.features.filter((f) => f.geometry.type === "Point"),
|
||||||
|
} as FeatureCollection;
|
||||||
|
|
||||||
|
return { polygons, points };
|
||||||
|
}
|
||||||
1
data
Submodule
1
data
Submodule
Submodule data added at 3aab49c029
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
97
lib/drawingEngine.ts
Normal file
97
lib/drawingEngine.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import { Geometry } from "@/lib/useEditorState";
|
||||||
|
|
||||||
|
type ModeGetter = () => "idle" | "draw" | "select" | "add-point";
|
||||||
|
|
||||||
|
export function initDrawing(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
getMode: ModeGetter,
|
||||||
|
onComplete: (geometry: Geometry) => void
|
||||||
|
) {
|
||||||
|
let coords: [number, number][] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close polygon ring if not closed.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update preview layer while drawing.
|
||||||
|
*/
|
||||||
|
function update(c: [number, number][]) {
|
||||||
|
const closed = closePolygon(c);
|
||||||
|
|
||||||
|
(map.getSource("draw-preview") as maplibregl.GeoJSONSource)?.setData({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
geometry: {
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: [closed],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||||
|
if (getMode() !== "draw") return;
|
||||||
|
|
||||||
|
coords.push([e.lngLat.lng, e.lngLat.lat]);
|
||||||
|
update(coords);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMove(e: maplibregl.MapLayerMouseEvent) {
|
||||||
|
if (getMode() !== "draw" || coords.length === 0) return;
|
||||||
|
|
||||||
|
const preview = [...coords, [e.lngLat.lng, e.lngLat.lat]];
|
||||||
|
update(preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize polygon, emit geometry to caller, reset preview.
|
||||||
|
*/
|
||||||
|
function finishDrawing() {
|
||||||
|
if (getMode() !== "draw" || coords.length < 3) return;
|
||||||
|
|
||||||
|
const geometry = {
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: [closePolygon(coords)],
|
||||||
|
};
|
||||||
|
|
||||||
|
onComplete(geometry);
|
||||||
|
|
||||||
|
coords = [];
|
||||||
|
|
||||||
|
map.getSource("draw-preview").setData({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
finishDrawing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map.on("click", onClick);
|
||||||
|
map.on("mousemove", onMove);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off("click", onClick);
|
||||||
|
map.off("mousemove", onMove);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}
|
||||||
211
lib/editingEngine.ts
Normal file
211
lib/editingEngine.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import { Geometry } from "@/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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 } };
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEditing = () => {
|
||||||
|
clearEditing();
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindEditEvents = (map: maplibregl.Map) => {
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopDragging = () => {
|
||||||
|
dragStateRef.current = null;
|
||||||
|
map.getCanvas().style.cursor = "";
|
||||||
|
map.dragPan.enable();
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Control") {
|
||||||
|
modifierRef.current.ctrl = false;
|
||||||
|
} else if (e.key === "Meta") {
|
||||||
|
modifierRef.current.meta = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
37
lib/pointEngine.ts
Normal file
37
lib/pointEngine.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import { Geometry } from "@/lib/useEditorState";
|
||||||
|
|
||||||
|
type ModeGetter = () => "idle" | "draw" | "select" | "add-point";
|
||||||
|
|
||||||
|
export function initPoint(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
getMode: ModeGetter,
|
||||||
|
onComplete: (geometry: Geometry) => void
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Add a new point when in add-point mode.
|
||||||
|
*/
|
||||||
|
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||||
|
if (getMode() !== "add-point") return;
|
||||||
|
|
||||||
|
const geometry = {
|
||||||
|
type: "Point",
|
||||||
|
coordinates: [e.lngLat.lng, e.lngLat.lat],
|
||||||
|
};
|
||||||
|
|
||||||
|
onComplete?.(geometry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMove() {
|
||||||
|
if (getMode() !== "add-point") return;
|
||||||
|
map.getCanvas().style.cursor = "crosshair";
|
||||||
|
}
|
||||||
|
|
||||||
|
map.on("click", onClick);
|
||||||
|
map.on("mousemove", onMove);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off("click", onClick);
|
||||||
|
map.off("mousemove", onMove);
|
||||||
|
};
|
||||||
|
}
|
||||||
198
lib/selectingEngine.ts
Normal file
198
lib/selectingEngine.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
|
||||||
|
type ModeGetter = () => "idle" | "draw" | "select" | "add-point";
|
||||||
|
|
||||||
|
export function initSelect(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
getMode: ModeGetter,
|
||||||
|
onDelete: (id: string | number) => void,
|
||||||
|
onEdit: (feature: maplibregl.MapGeoJSONFeature) => void
|
||||||
|
) {
|
||||||
|
const selectedIds = new Set<number | string>();
|
||||||
|
let contextMenu: HTMLDivElement | null = null;
|
||||||
|
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear feature-state highlight for all selected features.
|
||||||
|
*/
|
||||||
|
function clearSelection() {
|
||||||
|
if (!selectedIds.size) return;
|
||||||
|
selectedIds.forEach((id) => {
|
||||||
|
map.setFeatureState({ source: "countries", id }, { selected: false });
|
||||||
|
});
|
||||||
|
selectedIds.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select (or toggle) a feature. Holding Alt enables additive/toggle selection.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
map.setFeatureState({ source: "countries", id }, { selected: false });
|
||||||
|
selectedIds.delete(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
map.setFeatureState({ source: "countries", id }, { selected: true });
|
||||||
|
selectedIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||||
|
if (getMode() !== "select") return;
|
||||||
|
|
||||||
|
const features = map.queryRenderedFeatures(e.point, {
|
||||||
|
layers: ["countries-fill"],
|
||||||
|
}) as maplibregl.MapGeoJSONFeature[];
|
||||||
|
|
||||||
|
if (!features.length) {
|
||||||
|
clearSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const additive = !!e.originalEvent?.altKey;
|
||||||
|
selectFeature(features[0], additive);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show context menu (edit/delete) on right click.
|
||||||
|
*/
|
||||||
|
function onRightClick(e: maplibregl.MapLayerMouseEvent) {
|
||||||
|
if (getMode() !== "select") return;
|
||||||
|
|
||||||
|
e.preventDefault(); // block browser menu
|
||||||
|
|
||||||
|
const features = map.queryRenderedFeatures(e.point, {
|
||||||
|
layers: ["countries-fill"],
|
||||||
|
}) 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMove(e: maplibregl.MapLayerMouseEvent) {
|
||||||
|
if (getMode() !== "select") return;
|
||||||
|
|
||||||
|
const features = map.queryRenderedFeatures(e.point, {
|
||||||
|
layers: ["countries-fill"],
|
||||||
|
});
|
||||||
|
|
||||||
|
map.getCanvas().style.cursor = features.length ? "pointer" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
map.on("click", onClick);
|
||||||
|
map.on("mousemove", onMove);
|
||||||
|
map.on("contextmenu", onRightClick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off("click", onClick);
|
||||||
|
map.off("mousemove", onMove);
|
||||||
|
map.off("contextmenu", onRightClick);
|
||||||
|
hideContextMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
function hideContextMenu() {
|
||||||
|
if (contextMenu) {
|
||||||
|
contextMenu.remove();
|
||||||
|
contextMenu = null;
|
||||||
|
}
|
||||||
|
if (docClickHandler) {
|
||||||
|
document.removeEventListener("click", docClickHandler);
|
||||||
|
docClickHandler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a minimal context menu near cursor.
|
||||||
|
*/
|
||||||
|
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";
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (selectedCount === 1) {
|
||||||
|
const single = clickedFeature;
|
||||||
|
menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single)));
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
document.body.appendChild(menu);
|
||||||
|
contextMenu = menu;
|
||||||
|
|
||||||
|
const onDocClick = (ev: MouseEvent) => {
|
||||||
|
if (!menu.contains(ev.target as Node)) {
|
||||||
|
hideContextMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
docClickHandler = onDocClick;
|
||||||
|
setTimeout(() => document.addEventListener("click", onDocClick), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
296
lib/useEditorState.ts
Normal file
296
lib/useEditorState.ts
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic GeoJSON geometry union (no GeometryCollection).
|
||||||
|
*/
|
||||||
|
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 FeatureProperties = {
|
||||||
|
id: string | number;
|
||||||
|
time_start?: number | null;
|
||||||
|
time_end?: number | null;
|
||||||
|
kind?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Feature = {
|
||||||
|
type: "Feature";
|
||||||
|
properties: FeatureProperties;
|
||||||
|
geometry: Geometry;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FeatureCollection = {
|
||||||
|
type: "FeatureCollection";
|
||||||
|
features: Feature[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change map entry for saving.
|
||||||
|
*/
|
||||||
|
export type Change =
|
||||||
|
| { type: "create"; feature: Feature }
|
||||||
|
| { type: "update"; id: FeatureProperties["id"]; geometry: Geometry }
|
||||||
|
| { type: "delete"; id: FeatureProperties["id"] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal undo record.
|
||||||
|
*/
|
||||||
|
export type UndoAction =
|
||||||
|
| { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry }
|
||||||
|
| { type: "delete"; feature: Feature }
|
||||||
|
| { type: "create"; id: FeatureProperties["id"] };
|
||||||
|
|
||||||
|
const deepClone = <T,>(obj: T): T => JSON.parse(JSON.stringify(obj));
|
||||||
|
|
||||||
|
function geometryEquals(a: Geometry | undefined, b: Geometry | undefined): boolean {
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInitialMap(fc: FeatureCollection) {
|
||||||
|
const map = new Map<FeatureProperties["id"], Geometry>();
|
||||||
|
for (const f of fc.features) {
|
||||||
|
map.set(f.properties.id, deepClone(f.geometry));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffDraftToInitial(
|
||||||
|
draft: FeatureCollection,
|
||||||
|
initialMap: Map<FeatureProperties["id"], Geometry>
|
||||||
|
) {
|
||||||
|
const next = new Map<FeatureProperties["id"], Change>();
|
||||||
|
|
||||||
|
// track which initial ids are still present
|
||||||
|
const seen = new Set<FeatureProperties["id"]>();
|
||||||
|
|
||||||
|
// additions & updates
|
||||||
|
for (const f of draft.features) {
|
||||||
|
const id = f.properties.id;
|
||||||
|
seen.add(id);
|
||||||
|
const initialGeom = initialMap.get(id);
|
||||||
|
if (!initialGeom) {
|
||||||
|
next.set(id, { type: "create", feature: deepClone(f) });
|
||||||
|
} else if (!geometryEquals(initialGeom, f.geometry)) {
|
||||||
|
next.set(id, { type: "update", id, geometry: deepClone(f.geometry) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deletions
|
||||||
|
for (const [id] of initialMap.entries()) {
|
||||||
|
if (!seen.has(id)) {
|
||||||
|
next.set(id, { type: "delete", id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
|
||||||
|
if (!a) return false;
|
||||||
|
if (a.type !== b.type) return false;
|
||||||
|
switch (a.type) {
|
||||||
|
case "create":
|
||||||
|
return a.id === (b as UndoAction & { id: typeof a.id }).id;
|
||||||
|
case "delete": {
|
||||||
|
const bb = b as UndoAction & { feature: Feature };
|
||||||
|
return (
|
||||||
|
a.feature?.properties?.id === bb.feature?.properties?.id &&
|
||||||
|
geometryEquals(a.feature?.geometry, bb.feature?.geometry)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "update": {
|
||||||
|
const bb = b as UndoAction & { prevGeometry: Geometry };
|
||||||
|
return (
|
||||||
|
a.id === bb.id &&
|
||||||
|
geometryEquals(a.prevGeometry, bb.prevGeometry)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central state for the editor.
|
||||||
|
* - draft: source of truth for UI rendering
|
||||||
|
* - changes: map of pending changes for save
|
||||||
|
* - undoStack: minimal actions to revert last step
|
||||||
|
*/
|
||||||
|
export function useEditorState(initialData: FeatureCollection) {
|
||||||
|
const [draft, setDraft] = useState<FeatureCollection>(() => deepClone(initialData));
|
||||||
|
const [undoStack, setUndoStack] = useState<UndoAction[]>([]);
|
||||||
|
|
||||||
|
// baseline to know what is "saved" state
|
||||||
|
const initialMapRef = useRef<Map<FeatureProperties["id"], Geometry>>(
|
||||||
|
buildInitialMap(initialData)
|
||||||
|
);
|
||||||
|
const draftRef = useRef<FeatureCollection>(deepClone(initialData));
|
||||||
|
|
||||||
|
// central entrypoint: keep draftRef + React state in sync
|
||||||
|
const commitDraft = (nextDraft: FeatureCollection) => {
|
||||||
|
const cloned = deepClone(nextDraft);
|
||||||
|
draftRef.current = cloned;
|
||||||
|
setDraft(cloned);
|
||||||
|
};
|
||||||
|
|
||||||
|
// reset when initialData changes (e.g., after first load or after refresh)
|
||||||
|
useEffect(() => {
|
||||||
|
commitDraft(deepClone(initialData));
|
||||||
|
setUndoStack([]);
|
||||||
|
initialMapRef.current = buildInitialMap(initialData);
|
||||||
|
}, [initialData]);
|
||||||
|
|
||||||
|
// derive pending changes on every render: source of truth for save + changeCount.
|
||||||
|
// read baseline from state captured in closure to avoid ref access during render.
|
||||||
|
const [baselineVersion, setBaselineVersion] = useState(0);
|
||||||
|
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]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
draftRef.current = draft;
|
||||||
|
}, [draft]);
|
||||||
|
|
||||||
|
function pushUndo(action: UndoAction) {
|
||||||
|
setUndoStack((prev) => {
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
if (isSameUndo(last, action)) return prev; // tránh trùng lặp liên tiếp
|
||||||
|
return [...prev, action];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add new feature to draft and record "create".
|
||||||
|
*/
|
||||||
|
function createFeature(feature: Feature) {
|
||||||
|
const featureClone = deepClone(feature);
|
||||||
|
commitDraft({
|
||||||
|
...draftRef.current,
|
||||||
|
features: [...draftRef.current.features, featureClone],
|
||||||
|
});
|
||||||
|
pushUndo({ type: "create", id: featureClone.properties.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update geometry of an existing feature and record change.
|
||||||
|
*/
|
||||||
|
function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) {
|
||||||
|
const idx = draftRef.current.features.findIndex((f) => f.properties.id === id);
|
||||||
|
if (idx === -1) return; // nothing to update
|
||||||
|
|
||||||
|
const prevFeature = draftRef.current.features[idx];
|
||||||
|
const prevGeometry = prevFeature.geometry;
|
||||||
|
|
||||||
|
const updatedFeature = {
|
||||||
|
...prevFeature,
|
||||||
|
geometry: deepClone(newGeometry),
|
||||||
|
};
|
||||||
|
|
||||||
|
pushUndo({ type: "update", id, prevGeometry: deepClone(prevGeometry) });
|
||||||
|
|
||||||
|
const nextFeatures = [...draftRef.current.features];
|
||||||
|
nextFeatures[idx] = updatedFeature;
|
||||||
|
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a feature from draft and record delete.
|
||||||
|
*/
|
||||||
|
function deleteFeature(id: FeatureProperties["id"]) {
|
||||||
|
const idx = draftRef.current.features.findIndex((f) => f.properties.id === id);
|
||||||
|
if (idx === -1) return;
|
||||||
|
const feature = draftRef.current.features[idx];
|
||||||
|
|
||||||
|
// store undo
|
||||||
|
pushUndo({ type: "delete", feature: deepClone(feature) });
|
||||||
|
|
||||||
|
const nextFeatures = [...draftRef.current.features];
|
||||||
|
nextFeatures.splice(idx, 1);
|
||||||
|
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo last action, reverting both draft and change map.
|
||||||
|
*/
|
||||||
|
function undo() {
|
||||||
|
let applied = false; // guards against React StrictMode double invoke of setState updater
|
||||||
|
setUndoStack((prev) => {
|
||||||
|
if (applied) return prev;
|
||||||
|
if (!prev.length) return prev;
|
||||||
|
applied = true;
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
const remaining = prev.slice(0, -1);
|
||||||
|
|
||||||
|
switch (last.type) {
|
||||||
|
case "create": {
|
||||||
|
commitDraft({
|
||||||
|
...draftRef.current,
|
||||||
|
features: draftRef.current.features.filter((f) => f.properties.id !== last.id),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "delete": {
|
||||||
|
const feature = deepClone(last.feature);
|
||||||
|
commitDraft({
|
||||||
|
...draftRef.current,
|
||||||
|
features: [...draftRef.current.features, feature],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "update": {
|
||||||
|
const { id, prevGeometry } = last;
|
||||||
|
const idx = draftRef.current.features.findIndex((f) => f.properties.id === id);
|
||||||
|
if (idx === -1) return remaining;
|
||||||
|
const updated = {
|
||||||
|
...draftRef.current.features[idx],
|
||||||
|
geometry: deepClone(prevGeometry),
|
||||||
|
};
|
||||||
|
const nextFeatures = [...draftRef.current.features];
|
||||||
|
nextFeatures[idx] = updated;
|
||||||
|
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return remaining;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build payload array for API save.
|
||||||
|
*/
|
||||||
|
function buildPayload(): Change[] {
|
||||||
|
return Array.from(changes.values()).map((c) => deepClone(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear pending changes after successful save.
|
||||||
|
*/
|
||||||
|
function clearChanges() {
|
||||||
|
setUndoStack([]);
|
||||||
|
initialMapRef.current = buildInitialMap(draftRef.current);
|
||||||
|
setBaselineVersion((v) => v + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
draft,
|
||||||
|
changes,
|
||||||
|
undoStack,
|
||||||
|
changeCount,
|
||||||
|
createFeature,
|
||||||
|
updateFeature,
|
||||||
|
deleteFeature,
|
||||||
|
undo,
|
||||||
|
buildPayload,
|
||||||
|
clearChanges,
|
||||||
|
};
|
||||||
|
}
|
||||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
6849
package-lock.json
generated
Normal file
6849
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "ultimate-history-map",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"maplibre-gl": "^5.20.2",
|
||||||
|
"next": "16.1.7",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.1.7",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
BIN
public/point.png
Normal file
BIN
public/point.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user