feat: add region and location geometry types and extend editing engine to support line and point modifications

This commit is contained in:
taDuc
2026-06-01 16:01:37 +07:00
parent d270d9435b
commit 1a77d471ad
14 changed files with 623 additions and 114 deletions
-1
View File
@@ -41,7 +41,6 @@ export function ModeHint({ mode }: { mode: EditorMode }) {
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}> <div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
<div style={{ marginBottom: 4 }}>Click vào hình trên map đ Chọn (Select).</div> <div style={{ marginBottom: 4 }}>Click vào hình trên map đ Chọn (Select).</div>
<ul style={{ paddingLeft: 16, margin: 0, opacity: 0.85 }}> <ul style={{ paddingLeft: 16, margin: 0, opacity: 0.85 }}>
<li>(Khi đã chọn) Nhấp biểu tượng <b>Cây Bút</b> đ sửa đnh.</li>
<li>Trong chế đ Sửa đnh: <li>Trong chế đ Sửa đnh:
<ul style={{ paddingLeft: 16, margin: "2px 0 0 0" }}> <ul style={{ paddingLeft: 16, margin: "2px 0 0 0" }}>
<li><b>Enter</b>: Lưu hình đã sửa</li> <li><b>Enter</b>: Lưu hình đã sửa</li>
+107 -8
View File
@@ -496,8 +496,10 @@ export function buildPathArrowFeatureCollection(fc: FeatureCollection): FeatureC
if (!arrowGeometries) { if (!arrowGeometries) {
arrowGeometries = []; arrowGeometries = [];
const coordinateGroups = getLineCoordinateGroups(feature.geometry); const coordinateGroups = getLineCoordinateGroups(feature.geometry);
const featureType = getFeatureSemanticType(feature);
const isRetreat = featureType === "retreat_route";
for (const coordinates of coordinateGroups) { for (const coordinates of coordinateGroups) {
const geometry = buildPathArrowGeometry(coordinates); const geometry = buildPathArrowGeometry(coordinates, isRetreat);
if (geometry) arrowGeometries.push(geometry); if (geometry) arrowGeometries.push(geometry);
} }
pathArrowGeometriesCache.set(feature.geometry, arrowGeometries); pathArrowGeometriesCache.set(feature.geometry, arrowGeometries);
@@ -528,7 +530,7 @@ export function getFeatureSemanticType(feature: Feature): string | null {
return normalizeGeoTypeKey(value); return normalizeGeoTypeKey(value);
} }
export function buildPathArrowGeometry(coords: [number, number][]): Geometry | null { export function buildPathArrowGeometry(coords: [number, number][], isRetreatRoute = false): Geometry | null {
const sourceCoords = removeDuplicatePathCoords(coords); const sourceCoords = removeDuplicatePathCoords(coords);
if (sourceCoords.length < 2) return null; if (sourceCoords.length < 2) return null;
@@ -553,6 +555,96 @@ export function buildPathArrowGeometry(coords: [number, number][]): Geometry | n
const shoulderWidth = clampNumber(totalLength * 0.1, 10, 100000); const shoulderWidth = clampNumber(totalLength * 0.1, 10, 100000);
const headWidth = shoulderWidth * 2.0; const headWidth = shoulderWidth * 2.0;
const base = bodyPoints[bodyPoints.length - 1];
const tip = pointAtDistance(measured, totalLength);
const headNormal = normalFromSegment(base, tip) || normalAt(bodyPoints, bodyPoints.length - 1);
const headHalf = headWidth / 2;
if (isRetreatRoute) {
// Segmented Arrow (MultiPolygon)
const rings: [number, number][][] = [];
// 1. Generate body segments
const segmentLength = totalLength * 0.10; // Dash length
const gapLength = totalLength * 0.04; // Gap length
let currentD = 0;
while (currentD < bodyEndDistance) {
const startD = currentD;
const endD = Math.min(startD + segmentLength, bodyEndDistance - gapLength);
if (endD - startD > totalLength * 0.01) {
const segmentPoints: MeasuredPoint[] = [];
segmentPoints.push(pointAtDistance(measured, startD));
for (const p of bodyPoints) {
if (p.distance > startD && p.distance < endD) {
segmentPoints.push(p);
}
}
segmentPoints.push(pointAtDistance(measured, endD));
const leftBody: ProjectedPoint[] = [];
const rightBody: ProjectedPoint[] = [];
for (let i = 0; i < segmentPoints.length; i += 1) {
const point = segmentPoints[i];
const normal = normalAt(segmentPoints, i);
const progress = bodyEndDistance > 0
? Math.pow(clampNumber(point.distance / bodyEndDistance, 0, 1), 0.9)
: 0;
const width = tailWidth + (shoulderWidth - tailWidth) * progress;
const half = width / 2;
leftBody.push({
x: point.x + normal.x * half,
y: point.y + normal.y * half,
});
rightBody.push({
x: point.x - normal.x * half,
y: point.y - normal.y * half,
});
}
const ring = [
...leftBody,
...rightBody.reverse(),
leftBody[0],
].map((point) => unprojectLngLat(point, origin, cosOriginLat));
if (ring.length >= 4) {
rings.push(ring);
}
}
currentD += segmentLength + gapLength;
}
// 2. Generate head segment (standalone arrowhead chevron/triangle)
const headBaseLeft = {
x: base.x + headNormal.x * headHalf,
y: base.y + headNormal.y * headHalf,
};
const headBaseRight = {
x: base.x - headNormal.x * headHalf,
y: base.y - headNormal.y * headHalf,
};
const headRing = [
{ x: base.x, y: base.y },
headBaseLeft,
{ x: tip.x, y: tip.y },
headBaseRight,
{ x: base.x, y: base.y },
].map((point) => unprojectLngLat(point, origin, cosOriginLat));
rings.push(headRing);
return {
type: "MultiPolygon",
coordinates: rings.map(r => [r]),
};
} else {
// Continuous Arrow (Polygon)
const leftBody: ProjectedPoint[] = []; const leftBody: ProjectedPoint[] = [];
const rightBody: ProjectedPoint[] = []; const rightBody: ProjectedPoint[] = [];
@@ -574,10 +666,6 @@ export function buildPathArrowGeometry(coords: [number, number][]): Geometry | n
}); });
} }
const base = bodyPoints[bodyPoints.length - 1];
const tip = pointAtDistance(measured, totalLength);
const headNormal = normalFromSegment(base, tip) || normalAt(bodyPoints, bodyPoints.length - 1);
const headHalf = headWidth / 2;
const headBaseLeft = { const headBaseLeft = {
x: base.x + headNormal.x * headHalf, x: base.x + headNormal.x * headHalf,
y: base.y + headNormal.y * headHalf, y: base.y + headNormal.y * headHalf,
@@ -602,6 +690,7 @@ export function buildPathArrowGeometry(coords: [number, number][]): Geometry | n
coordinates: [ring], coordinates: [ring],
}; };
} }
}
export type ProjectedPoint = { export type ProjectedPoint = {
x: number; x: number;
@@ -833,11 +922,21 @@ function createFeatureLabelResolver(
return (feature) => { return (feature) => {
const featureId = String(feature.properties.id); const featureId = String(feature.properties.id);
const directEntityIds = getFeatureEntityIds(feature); const directEntityIds = getFeatureEntityIds(feature);
let label: string | null = null;
if (directEntityIds.length > 0) { if (directEntityIds.length > 0) {
return directLabelsByFeatureId.get(featureId)?.label || null; label = directLabelsByFeatureId.get(featureId)?.label || null;
} else {
label = inheritedLabelsByChildId.get(featureId)?.label || null;
} }
return inheritedLabelsByChildId.get(featureId)?.label || null; if (!label) {
const geotype = feature.properties?.type || feature.properties?.entity_type_id;
if (geotype === "region") {
return "__Missing__";
}
}
return label;
}; };
} }
+3 -2
View File
@@ -333,8 +333,9 @@ export function useMapInteraction({
drawingEngine.cleanup drawingEngine.cleanup
); );
if (allowGeometryEditing) { const editCleanup = editingEngineRef.current?.bindEditEvents(map);
editingEngineRef.current?.bindEditEvents(map); if (editCleanup) {
mapCleanupFnsRef.current.push(editCleanup);
} }
}; };
+26
View File
@@ -187,6 +187,19 @@ export function setupMapLayers(
data: { type: "FeatureCollection", features: [] }, data: { type: "FeatureCollection", features: [] },
}); });
// Glowing halo under the edit shape line
map.addLayer({
id: "edit-shape-glow",
type: "line",
source: "edit-shape",
paint: {
"line-color": "#38bdf8",
"line-width": 8,
"line-opacity": 0.35,
"line-blur": 1.5,
},
});
map.addLayer({ map.addLayer({
id: "edit-shape-line", id: "edit-shape-line",
type: "line", type: "line",
@@ -197,6 +210,19 @@ export function setupMapLayers(
}, },
}); });
// Glowing halo under the edit handles
map.addLayer({
id: "edit-handles-glow",
type: "circle",
source: "edit-handles",
paint: {
"circle-color": "#f97316",
"circle-radius": 22,
"circle-opacity": 0.35,
"circle-blur": 0.85,
},
});
map.addLayer({ map.addLayer({
id: "edit-handles-circle", id: "edit-handles-circle",
type: "circle", type: "circle",
+1 -1
View File
@@ -200,7 +200,7 @@ export function useMapSync({
applyRenderDraftToMap(renderDraft, labelContextDraft, selectedFeatureIds); applyRenderDraftToMap(renderDraft, labelContextDraft, selectedFeatureIds);
const editingId = editingEngineRef.current?.editingRef?.current?.id; const editingId = editingEngineRef.current?.editingRef?.current?.id;
if (allowGeometryEditing && editingId !== undefined && editingId !== null) { if (allowGeometryEditing && editingId !== undefined && editingId !== null) {
const stillExists = renderDraft.features.some((f) => f.properties.id === editingId); const stillExists = renderDraft.features.some((f) => String(f.properties.id) === String(editingId));
if (!stillExists) { if (!stillExists) {
editingEngineRef.current?.clearEditing(); editingEngineRef.current?.clearEditing();
} }
+137 -18
View File
@@ -10,15 +10,16 @@ export type EditingHandle = {
isCircle?: boolean; isCircle?: boolean;
circleCenter?: [number, number]; circleCenter?: [number, number];
circleRadius?: number; circleRadius?: number;
geometryType?: "Point" | "LineString" | "Polygon";
}; };
export type EditingAPI = { export type EditingAPI = {
beginEditing: (feature: maplibregl.MapGeoJSONFeature) => void; beginEditing: (feature: maplibregl.MapGeoJSONFeature) => void;
clearEditing: () => void; clearEditing: () => void;
bindEditEvents: (map: maplibregl.Map) => void; bindEditEvents: (map: maplibregl.Map) => (() => void);
}; };
// Tạo engine chỉnh sửa polygon đã có (kéo đỉnh, thêm đỉnh, commit/cancel). // Tạo engine chỉnh sửa polygon, line, point đã có (kéo đỉnh, thêm đỉnh, commit/cancel).
export function createEditingEngine(options: { export function createEditingEngine(options: {
mapRef: React.MutableRefObject<maplibregl.Map | null>; mapRef: React.MutableRefObject<maplibregl.Map | null>;
onUpdate: (id: string | number, geometry: Geometry) => void; onUpdate: (id: string | number, geometry: Geometry) => void;
@@ -43,15 +44,19 @@ export function createEditingEngine(options: {
(map.getSource("edit-handles") 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. // Đồng bộ polygon/line/point tạm và các handle point lên map source.
const updateEditSources = () => { const updateEditSources = () => {
const editing = editingRef.current; const editing = editingRef.current;
const map = mapRef.current; const map = mapRef.current;
console.log("updateEditSources: editing:", editing, "map loaded:", map?.isStyleLoaded());
if (!editing || !map || !map.isStyleLoaded()) return; if (!editing || !map || !map.isStyleLoaded()) return;
let shape: GeoJSON.FeatureCollection<GeoJSON.Polygon>; let shape: GeoJSON.FeatureCollection<GeoJSON.Polygon | GeoJSON.LineString | GeoJSON.Point>;
let handles: GeoJSON.FeatureCollection<GeoJSON.Point>; let handles: GeoJSON.FeatureCollection<GeoJSON.Point>;
const geomType = editing.geometryType || "Polygon";
if (geomType === "Polygon") {
if (editing.isCircle && editing.circleCenter && editing.circleRadius !== undefined) { if (editing.isCircle && editing.circleCenter && editing.circleRadius !== undefined) {
const ring = buildCircleRing(editing.circleCenter, editing.circleRadius); const ring = buildCircleRing(editing.circleCenter, editing.circleRadius);
const closedRing = [...ring, ring[0]]; const closedRing = [...ring, ring[0]];
@@ -105,6 +110,44 @@ export function createEditingEngine(options: {
})), })),
}; };
} }
} else if (geomType === "LineString") {
shape = {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: { type: "LineString", coordinates: editing.ring },
properties: {},
},
],
};
handles = {
type: "FeatureCollection",
features: editing.ring.map((c, idx) => ({
type: "Feature",
geometry: { type: "Point", coordinates: c },
properties: { idx },
})),
};
} else {
// Point
shape = {
type: "FeatureCollection",
features: [],
};
handles = {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: { type: "Point", coordinates: editing.ring[0] },
properties: { idx: 0 },
},
],
};
}
(map.getSource("edit-shape") as maplibregl.GeoJSONSource | undefined)?.setData(shape); (map.getSource("edit-shape") as maplibregl.GeoJSONSource | undefined)?.setData(shape);
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(handles); (map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(handles);
@@ -116,6 +159,9 @@ export function createEditingEngine(options: {
if (!editing) return; if (!editing) return;
let geometry: Geometry; let geometry: Geometry;
const geomType = editing.geometryType || "Polygon";
if (geomType === "Polygon") {
if (editing.isCircle && editing.circleCenter && editing.circleRadius !== undefined) { if (editing.isCircle && editing.circleCenter && editing.circleRadius !== undefined) {
const ring = buildCircleRing(editing.circleCenter, editing.circleRadius); const ring = buildCircleRing(editing.circleCenter, editing.circleRadius);
geometry = { geometry = {
@@ -130,6 +176,18 @@ export function createEditingEngine(options: {
coordinates: [[...editing.ring, editing.ring[0]]], coordinates: [[...editing.ring, editing.ring[0]]],
}; };
} }
} else if (geomType === "LineString") {
geometry = {
type: "LineString",
coordinates: editing.ring,
};
} else {
// Point
geometry = {
type: "Point",
coordinates: editing.ring[0],
};
}
onUpdate(editing.id, geometry); onUpdate(editing.id, geometry);
clearEditing(); clearEditing();
@@ -146,19 +204,56 @@ export function createEditingEngine(options: {
if (!map || !map.isStyleLoaded() || !map.getLayer("edit-handles-circle")) return; if (!map || !map.isStyleLoaded() || !map.getLayer("edit-handles-circle")) return;
map.setPaintProperty("edit-handles-circle", "circle-color", enabled ? "#ef4444" : "#f97316"); map.setPaintProperty("edit-handles-circle", "circle-color", enabled ? "#ef4444" : "#f97316");
map.setPaintProperty("edit-handles-circle", "circle-stroke-color", enabled ? "#7f1d1d" : "#0f172a"); map.setPaintProperty("edit-handles-circle", "circle-stroke-color", enabled ? "#7f1d1d" : "#0f172a");
if (map.getLayer("edit-handles-glow")) {
map.setPaintProperty("edit-handles-glow", "circle-color", enabled ? "#ef4444" : "#f97316");
}
}; };
// Bắt đầu chỉnh sửa từ feature polygon được chọn. // Bắt đầu chỉnh sửa từ feature polygon/line/point được chọn.
const beginEditing = (feature: maplibregl.MapGeoJSONFeature) => { const beginEditing = (feature: maplibregl.MapGeoJSONFeature) => {
if (feature.geometry.type !== "Polygon") return; console.log("beginEditing called with feature:", feature);
if (!feature || !feature.geometry) {
console.warn("beginEditing: feature or feature.geometry is missing");
return;
}
const geom = feature.geometry as Geometry; const geom = feature.geometry as Geometry;
const coords = (geom.coordinates?.[0] ?? []) as [number, number][]; const type = geom.type;
if (coords.length < 4) return; console.log("beginEditing: geometry type is", type);
if (type !== "Polygon" && type !== "LineString" && type !== "Point") {
console.warn("beginEditing: unsupported geometry type:", type);
return;
}
const isCircle = !!geom.circle_center; const isCircle = !!geom.circle_center;
let ring: [number, number][] = [];
if (type === "Polygon") {
const coords = (geom.coordinates?.[0] ?? []) as [number, number][];
console.log("beginEditing Polygon coords:", coords);
if (coords.length < 4) {
console.warn("beginEditing: Polygon coords length is less than 4");
return;
}
// remove duplicated closing point // remove duplicated closing point
const ring = coords.slice(0, -1).map((c) => [c[0], c[1]] as [number, number]); ring = coords.slice(0, -1).map((c) => [c[0], c[1]] as [number, number]);
} else if (type === "LineString") {
const coords = (geom.coordinates ?? []) as [number, number][];
console.log("beginEditing LineString coords:", coords);
if (coords.length < 2) {
console.warn("beginEditing: LineString coords length is less than 2");
return;
}
ring = coords.map((c) => [c[0], c[1]] as [number, number]);
} else if (type === "Point") {
const coords = (geom.coordinates ?? []) as [number, number];
console.log("beginEditing Point coords:", coords);
if (coords.length < 2) {
console.warn("beginEditing: Point coords length is less than 2");
return;
}
ring = [[coords[0], coords[1]]];
}
editingRef.current = { editingRef.current = {
id: feature.id ?? feature.properties?.id, id: feature.id ?? feature.properties?.id,
ring, ring,
@@ -166,7 +261,9 @@ export function createEditingEngine(options: {
isCircle, isCircle,
circleCenter: geom.circle_center, circleCenter: geom.circle_center,
circleRadius: geom.circle_radius, circleRadius: geom.circle_radius,
geometryType: type,
}; };
console.log("beginEditing: initialized editingRef.current:", editingRef.current);
setDeleteVertexMode(false); setDeleteVertexMode(false);
updateEditSources(); updateEditSources();
}; };
@@ -192,8 +289,8 @@ export function createEditingEngine(options: {
const idx = Number(feature?.properties?.idx); const idx = Number(feature?.properties?.idx);
if (!Number.isInteger(idx)) return; if (!Number.isInteger(idx)) return;
e.preventDefault(); e.preventDefault();
e.originalEvent.stopPropagation(); // Chặn sự kiện lan ra bản đồ tránh gây kéo/pan bản đồ
if (deleteVertexModeRef.current) { if (deleteVertexModeRef.current) {
e.originalEvent.stopPropagation();
deleteVertex(idx); deleteVertex(idx);
return; return;
} }
@@ -240,7 +337,7 @@ export function createEditingEngine(options: {
if (!editing) return; if (!editing) return;
if (e.key === "Enter") { if (e.key === "Enter") {
finishEditing(); finishEditing();
} else if (e.key === "Delete" && !editing.isCircle) { } else if (e.key === "Delete" && editing.geometryType !== "Point" && !editing.isCircle) {
e.preventDefault(); e.preventDefault();
setDeleteVertexMode(!deleteVertexModeRef.current); setDeleteVertexMode(!deleteVertexModeRef.current);
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
@@ -256,7 +353,7 @@ export function createEditingEngine(options: {
// Chuột phải vào handle để mở menu xóa/thêm đỉnh. // Chuột phải vào handle để mở menu xóa/thêm đỉnh.
const onHandleContextMenu = (e: maplibregl.MapLayerMouseEvent) => { const onHandleContextMenu = (e: maplibregl.MapLayerMouseEvent) => {
const editing = editingRef.current; const editing = editingRef.current;
if (!editing || editing.isCircle) return; if (!editing || editing.geometryType === "Point" || editing.isCircle) return;
e.preventDefault(); e.preventDefault();
e.originalEvent.stopPropagation(); e.originalEvent.stopPropagation();
const feature = e.features?.[0]; const feature = e.features?.[0];
@@ -281,15 +378,24 @@ export function createEditingEngine(options: {
document.addEventListener("keydown", onKeyDown); document.addEventListener("keydown", onKeyDown);
map.getCanvas().addEventListener("mouseleave", onCanvasLeave); map.getCanvas().addEventListener("mouseleave", onCanvasLeave);
map.on("remove", () => { const cleanup = () => {
map.off("mousedown", "edit-handles-circle", onHandleDown); map.off("mousedown", "edit-handles-circle", onHandleDown);
map.off("contextmenu", "edit-handles-circle", onHandleContextMenu); map.off("contextmenu", "edit-handles-circle", onHandleContextMenu);
map.off("mousemove", onHandleMove); map.off("mousemove", onHandleMove);
map.off("mouseup", stopDragging); map.off("mouseup", stopDragging);
document.removeEventListener("keydown", onKeyDown); document.removeEventListener("keydown", onKeyDown);
map.getCanvas().removeEventListener("mouseleave", onCanvasLeave); try {
map.getCanvas()?.removeEventListener("mouseleave", onCanvasLeave);
} catch {
// ignore
}
hideContextMenu(); hideContextMenu();
}); map.off("remove", cleanup);
};
map.on("remove", cleanup);
return cleanup;
}; };
const showHandleContextMenu = (x: number, y: number, idx: number) => { const showHandleContextMenu = (x: number, y: number, idx: number) => {
@@ -328,9 +434,16 @@ export function createEditingEngine(options: {
}; };
const editing = editingRef.current; const editing = editingRef.current;
const canDelete = Boolean(editing && !editing.isCircle && editing.ring.length > 3); if (!editing) return;
const isLine = editing.geometryType === "LineString";
const canDelete = isLine ? editing.ring.length > 2 : editing.ring.length > 3;
const canInsert = isLine ? idx < editing.ring.length - 1 : true;
menu.appendChild(createItem("Xóa đỉnh", () => deleteVertex(idx), !canDelete)); menu.appendChild(createItem("Xóa đỉnh", () => deleteVertex(idx), !canDelete));
if (canInsert) {
menu.appendChild(createItem("Thêm đỉnh", () => insertVertexAfter(idx))); menu.appendChild(createItem("Thêm đỉnh", () => insertVertexAfter(idx)));
}
document.body.appendChild(menu); document.body.appendChild(menu);
contextMenu = menu; contextMenu = menu;
@@ -346,7 +459,10 @@ export function createEditingEngine(options: {
const deleteVertex = (idx: number) => { const deleteVertex = (idx: number) => {
const editing = editingRef.current; const editing = editingRef.current;
if (!editing || editing.isCircle || editing.ring.length <= 3) return; if (!editing || editing.geometryType === "Point" || editing.isCircle) return;
const isLine = editing.geometryType === "LineString";
const minLength = isLine ? 2 : 3;
if (editing.ring.length <= minLength) return;
if (idx < 0 || idx >= editing.ring.length) return; if (idx < 0 || idx >= editing.ring.length) return;
editing.ring.splice(idx, 1); editing.ring.splice(idx, 1);
updateEditSources(); updateEditSources();
@@ -354,8 +470,11 @@ export function createEditingEngine(options: {
const insertVertexAfter = (idx: number) => { const insertVertexAfter = (idx: number) => {
const editing = editingRef.current; const editing = editingRef.current;
if (!editing || editing.isCircle || editing.ring.length < 2) return; if (!editing || editing.geometryType === "Point" || editing.isCircle || editing.ring.length < 2) return;
if (idx < 0 || idx >= editing.ring.length) return; if (idx < 0 || idx >= editing.ring.length) return;
const isLine = editing.geometryType === "LineString";
if (isLine && idx === editing.ring.length - 1) return;
const current = editing.ring[idx]; const current = editing.ring[idx];
const next = editing.ring[(idx + 1) % editing.ring.length]; const next = editing.ring[(idx + 1) % editing.ring.length];
const midpoint: [number, number] = [ const midpoint: [number, number] = [
+5 -2
View File
@@ -367,10 +367,13 @@ export function initSelect(
const canEditGeometry = allowGeometryEditing ? allowGeometryEditing() : true; const canEditGeometry = allowGeometryEditing ? allowGeometryEditing() : true;
if (isLocalTarget && !isClickOutsideSelection && canEditGeometry) { if (isLocalTarget && !isClickOutsideSelection && canEditGeometry) {
const isEditableType =
(clickedFeature.source === "countries" && (clickedFeature.geometry?.type === "Polygon" || clickedFeature.geometry?.type === "LineString")) ||
(clickedFeature.source === "places" && clickedFeature.geometry?.type === "Point");
if ( if (
effectiveCount === 1 && effectiveCount === 1 &&
clickedFeature.source === "countries" && isEditableType &&
clickedFeature.geometry?.type === "Polygon" &&
onEdit onEdit
) { ) {
const single = clickedFeature; const single = clickedFeature;
+2
View File
@@ -7,6 +7,8 @@
{ "type_key": "country", "geo_type_code": 9, "fixed": true }, { "type_key": "country", "geo_type_code": 9, "fixed": true },
{ "type_key": "state", "geo_type_code": 10 }, { "type_key": "state", "geo_type_code": 10 },
{ "type_key": "region", "geo_type_code": 11 },
{ "type_key": "location", "geo_type_code": 12 },
{ "type_key": "faction", "geo_type_code": 28 }, { "type_key": "faction", "geo_type_code": 28 },
{ "type_key": "battle", "geo_type_code": 14 }, { "type_key": "battle", "geo_type_code": 14 },
@@ -75,6 +75,8 @@ const RAW_GEOMETRY_TYPE_OPTIONS: Array<{
{ value: "rebellion_zone", label: "Rebellion Zone", groupId: "circle", geometryPreset: "circle-area" }, { value: "rebellion_zone", label: "Rebellion Zone", groupId: "circle", geometryPreset: "circle-area" },
{ value: "person_event", label: "Person Event", groupId: "point", geometryPreset: "point" }, { value: "person_event", label: "Person Event", groupId: "point", geometryPreset: "point" },
{ value: "region", label: "Region (Vùng nhãn tên)", groupId: "point", geometryPreset: "point" },
{ value: "location", label: "Location (Địa điểm chấm)", groupId: "point", geometryPreset: "point" },
{ value: "temple", label: "Temple", groupId: "point", geometryPreset: "point" }, { value: "temple", label: "Temple", groupId: "point", geometryPreset: "point" },
{ value: "capital", label: "Capital", groupId: "point", geometryPreset: "point" }, { value: "capital", label: "Capital", groupId: "point", geometryPreset: "point" },
{ value: "city", label: "City", groupId: "point", geometryPreset: "point" }, { value: "city", label: "City", groupId: "point", geometryPreset: "point" },
+5 -1
View File
@@ -19,6 +19,8 @@ import { getCityLayers } from "./geotypes/city";
import { getFortificationLayers } from "./geotypes/fortification"; import { getFortificationLayers } from "./geotypes/fortification";
import { getRuinLayers } from "./geotypes/ruin"; import { getRuinLayers } from "./geotypes/ruin";
import { getPortLayers } from "./geotypes/port"; import { getPortLayers } from "./geotypes/port";
import { getRegionLayers } from "./geotypes/region";
import { getLocationLayers } from "./geotypes/location";
import { getLineLabelLayers } from "./shared/lineLabels"; import { getLineLabelLayers } from "./shared/lineLabels";
import { getPolygonLabelLayers } from "./shared/polygonLabels"; import { getPolygonLabelLayers } from "./shared/polygonLabels";
@@ -42,7 +44,9 @@ export function getAllGeotypeLayers(sourceId: string, pathArrowSourceId?: string
...getCityLayers(sourceId, pathArrowSourceId, pointSourceId), ...getCityLayers(sourceId, pathArrowSourceId, pointSourceId),
...getFortificationLayers(sourceId, pathArrowSourceId, pointSourceId), ...getFortificationLayers(sourceId, pathArrowSourceId, pointSourceId),
...getRuinLayers(sourceId, pathArrowSourceId, pointSourceId), ...getRuinLayers(sourceId, pathArrowSourceId, pointSourceId),
...getPortLayers(sourceId, pathArrowSourceId, pointSourceId) ...getPortLayers(sourceId, pathArrowSourceId, pointSourceId),
...getRegionLayers(sourceId, pathArrowSourceId, pointSourceId),
...getLocationLayers(sourceId, pathArrowSourceId, pointSourceId)
]; ];
} }
@@ -0,0 +1,96 @@
import { LayerSpecification } from "maplibre-gl";
import { MAP_EMPHASIS_TEXT_FONT_STACK } from "../shared/textFonts";
const TYPE_MATCH_EXPR: any = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""];
const POINT_GEOMETRY_FILTER: any = [
"any",
["==", ["geometry-type"], "Point"],
["==", ["geometry-type"], "MultiPoint"],
];
const SELECTED_EXPR: any = ["boolean", ["feature-state", "selected"], false];
const SELECTED_COLOR = "#22c55e";
export function getLocationLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId;
void pathArrowSourceId;
const typeId = "location";
const filter: any = ["all", POINT_GEOMETRY_FILTER, ["==", TYPE_MATCH_EXPR, typeId]];
return [
{
id: `${typeId}-selected-halo`,
type: "circle",
source: pointSourceId!,
filter: filter,
paint: {
"circle-color": "#e2e8f0",
"circle-radius": ["case", SELECTED_EXPR, 18, 0],
"circle-opacity": ["case", SELECTED_EXPR, 0.24, 0],
"circle-blur": ["case", SELECTED_EXPR, 0.8, 0],
"circle-stroke-color": "#64748b",
"circle-stroke-width": ["case", SELECTED_EXPR, 1.6, 0],
"circle-stroke-opacity": ["case", SELECTED_EXPR, 0.48, 0],
},
},
{
id: `${typeId}-circle`,
type: "circle",
source: pointSourceId!,
filter: filter,
paint: {
"circle-color": [
"case",
SELECTED_EXPR,
SELECTED_COLOR,
["coalesce", ["get", "entity_color"], "#e2e8f0"],
],
"circle-radius": [
"interpolate",
["linear"],
["zoom"],
1, 2.5,
4, 3.5,
6, 4.5,
],
"circle-stroke-color": [
"case",
SELECTED_EXPR,
SELECTED_COLOR,
["coalesce", ["get", "entity_color"], "#475569"],
],
"circle-stroke-width": 1.5,
},
},
{
id: `${typeId}-label`,
type: "symbol",
source: pointSourceId!,
filter: filter,
layout: {
"text-font": [...MAP_EMPHASIS_TEXT_FONT_STACK],
"text-field": ["coalesce", ["get", "point_label"], ""],
"text-size": [
"interpolate",
["linear"],
["zoom"],
1, 10.5,
4, 12,
6, 14,
],
"text-anchor": "top",
"text-offset": [0, 0.6],
"text-allow-overlap": true,
"text-ignore-placement": true,
"text-optional": true,
"text-max-width": 12,
},
paint: {
"text-color": "#f8fafc",
"text-halo-color": "#0f172a",
"text-halo-width": 1.4,
"text-halo-blur": 0.3,
},
}
];
}
+56
View File
@@ -0,0 +1,56 @@
import { LayerSpecification } from "maplibre-gl";
import { MAP_EMPHASIS_TEXT_FONT_STACK } from "../shared/textFonts";
const TYPE_MATCH_EXPR: any = ["coalesce", ["get", "type"], ["get", "entity_type_id"], ""];
const POINT_GEOMETRY_FILTER: any = [
"any",
["==", ["geometry-type"], "Point"],
["==", ["geometry-type"], "MultiPoint"],
];
const SELECTED_EXPR: any = ["boolean", ["feature-state", "selected"], false];
const SELECTED_COLOR = "#22c55e";
export function getRegionLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
void sourceId;
void pathArrowSourceId;
const typeId = "region";
const filter: any = ["all", POINT_GEOMETRY_FILTER, ["==", TYPE_MATCH_EXPR, typeId]];
return [
{
id: `${typeId}-label`,
type: "symbol",
source: pointSourceId!,
filter: filter,
layout: {
"text-font": [...MAP_EMPHASIS_TEXT_FONT_STACK],
"text-field": ["coalesce", ["get", "point_label"], ""],
"text-size": [
"interpolate",
["linear"],
["zoom"],
1, 11,
4, 13,
6, 16,
],
"text-anchor": "center",
"text-allow-overlap": true,
"text-ignore-placement": true,
"text-max-width": 12,
},
paint: {
"text-color": [
"case",
SELECTED_EXPR,
SELECTED_COLOR,
"#f8fafc",
],
"text-halo-color": "#0f172a",
"text-halo-width": 1.6,
"text-halo-blur": 0.5,
},
}
];
}
@@ -7,7 +7,6 @@ export function getRetreatRouteLayers(sourceId: string, pathArrowSourceId?: stri
typeId: "retreat_route", typeId: "retreat_route",
color: "#94a3b8", color: "#94a3b8",
strokeColor: "#475569", strokeColor: "#475569",
dasharray: [6, 3],
opacity: 0.82, opacity: 0.82,
arrowOpacity: 0.68, arrowOpacity: 0.68,
showLine: false, showLine: false,
+103
View File
@@ -0,0 +1,103 @@
# Lịch Sử Kháng Chiến Chống Quân Nguyên - Mông Lần Thứ Hai (1285)
## 1. Bối Cảnh Lịch Sử & Tiền Đề Diễn Biến
* **1258:** Quân Mông Cổ thất bại lần thứ nhất khi tiến hành xâm lược Đại Việt.
* **1279:** Nam Tống hoàn toàn bị nhà Nguyên thôn tính. Vào tháng 8, nhà Nguyên bắt đầu đóng chiến thuyền, chuẩn bị lực lượng toàn diện để đánh Đại Việt.
## 2. Những Đụng Độ Đầu Tiên
* **1281:** Vua Nguyên đòi vua Trần sang chầu. Vua Trần từ chối và cử Trần Di Ái đi thế mạng.
* **27/11/1281:** Nhà Nguyên lập một đạo quân hộ tống Trần Di Ái về Đại Việt để lập làm vua bù nhìn. Tuy nhiên, vua Trần Nhân Tông đã sai người chặn đánh và đuổi thẳng về nước.
## 3. Mặt Trận Láng Giềng
* **1282:** Tướng Nguyên là Toa Đô dẫn quân đánh Chiêm Thành (Champa).
* **1283:** Nhà Nguyên tiến hành sáp nhập Kinh Hồ - Chiêm Thành nhằm tạo lập thế gọng kìm, chuẩn bị đánh Đại Việt từ phía Nam.
## 4. Chuẩn Bị Và Củng Cố Lực Lượng
### Phía Mông Nguyên:
* **21/07/1284:** Thoát Hoan được phong làm Trấn Nam vương, nhận lệnh tổng chỉ huy chiến dịch.
* Tướng Tangutai (Tang-gô-đai) được điều động đến Chiêm Thành để phối hợp chuẩn bị chinh phạt Đại Việt.
### Phía Nhà Trần:
* **Cuối tháng 11 - đầu tháng 12/1282:** Ngay sau khi nhận được tin tình báo về ý đồ của nhà Nguyên, vua Trần đã triệu tập hội nghị quân sự tại **Bình Than** để "bàn kế đánh phòng" và "chia quân giữ nơi hiểm yếu". Tất cả các tướng lĩnh từng phạm tội (như Trần Khánh Dư) đều được tha tội để đến hội nghị cùng bàn việc nước.
* **Trần Quốc Tuấn** viết *Hịch tướng sĩ* để nâng cao tinh thần binh sĩ. Nhiều chiến sĩ Đại Việt đã tự xăm lên tay hai chữ **"Sát Thát"** (Giết giặc Thát Đát).
* **Tháng 12 năm Giáp Thân (khoảng tháng 1 - đầu tháng 2/1285):** Thái thượng hoàng Trần Thánh Tông mời những bậc phụ lão tuổi cao có uy tín trong cả nước về điện **Diên Hồng** ở kinh đô Thăng Long để triều đình tham vấn ý kiến. *Đại Việt sử ký toàn thư* chép rằng, khi được vua hỏi có nên đánh lại quân Nguyên hay không, các phụ lão đã *"vạn người cùng nói như từ một miệng"*: **"Đánh!"**.
---
## 5. Diễn Biến Chi Tiết Cuộc Chiến (1285)
### GIAI ĐOẠN 1: ĐỊCH PHUN VŨ BÃO TA CHỦ ĐỘNG LUI BINH (Tháng 1 Tháng 2/1285)
#### Cuối tháng 1/1285: Mở màn trên biên giới phía Bắc
* **Mũi tiến công của Nguyên - Mông:**
* **Thoát Hoan** (Tổng tư lệnh) dẫn quân chủ lực tràn qua cửa ải Khả Ly, Lạng Sơn.
* Tướng **Kỳ Đạt** dẫn một mũi phụ đánh vào Cao Bằng, Cao Lộc.
* **Hành động của quân Trần:**
* **Trần Quốc Tuấn** (Tiết chế) trực tiếp dàn quân chặn địch tại Nội Bàng (Bắc Giang) và Chi Lăng.
* Sau vài trận chạm trán nảy lửa để đo sức mạnh giặc, nhận thấy thế địch quá mạnh, Trần Quốc Tuấn lệnh cho các cánh quân chủ động rút lui chiến thuật về giữ phòng tuyến Vạn Kiếp (Chí Linh, Hải Dương).
#### Đầu tháng 2/1285: Đại chiến Vạn Kiếp và cuộc rút lui chiến lược
* **Mũi tiến công của Nguyên - Mông:** Thoát Hoan tung toàn lực bao vây Vạn Kiếp từ nhiều phía, quyết tâm tiêu diệt cơ quan đầu não của quân Trần.
* **Hành động của quân Trần:**
* Quân Trần chống trả quyết liệt tại Vạn Kiếp nhưng sau đó tiếp tục rút lui bằng đường thủy về Thăng Long để bảo toàn lực lượng.
* Hai vua Trần (Trần Thánh Tông và Trần Nhân Tông) hạ lệnh di tản toàn bộ nhân dân khỏi kinh thành Thăng Long, thực hiện chiến thuật **"Vườn không nhà trống"**. Hoàng gia và đại quân rút xuôi về vùng Thiên Trường (Nam Định).
* **Trần Bình Trọng** được giao trọng trách dẫn một đạo quân chặn hậu tại bến Thiên Mạc (Hưng Yên). Ông chiến đấu đến cùng, bị bắt và để lại câu nói bất tử trước khi bị hành quyết:
> "Thà làm quỷ nước Nam, chứ không thèm làm vương đất Bắc"
#### Cuối tháng 2/1285: Gọng kìm phía Nam kích hoạt
* **Mũi tiến công của Nguyên - Mông:** **Toa Đô** dẫn hàng vạn quân từ Chiêm Thành (phía Nam) đánh ngược lên Thanh Hóa - Nghệ An, tạo thành thế gọng kìm kẹp chặt quân Trần ở giữa.
* **Hành động của quân Trần:**
* Tướng nhà Trần giữ mặt trận phía Nam là **Trần Kiện** khiếp sợ, dẫn đầu hạ cấp đầu hàng Toa Đô.
* Trần Quốc Tuấn lập tức điều **Trần Quang Khải** vào trấn thủ Nghệ An để tổ chức lại phòng tuyến, chặn không cho Toa Đô tiến ra Bắc hội quân với Thoát Hoan.
---
### GIAI ĐOẠN 2: THỬ THÁCH NGHẸT THỞ CÚ ĐẢO NGÔI KINH ĐIỂN (Tháng 3 Tháng 4/1285)
Đây là giai đoạn căng thẳng nhất, quân Nguyên truy quét gắt gao nhằm "bắt sống" hai vua Trần.
```
[Thoát Hoan từ Bắc đánh xuống]
[Hải Phòng / Quảng Ninh] ◄── (Vua Trần vượt biển thoát hiểm)
[Toa Đô từ Nam đánh ngược lên]
```
* **Mũi tiến công của Nguyên - Mông:** Thoát Hoan chiếm Thăng Long trống rỗng, tức giận sai **Ô Mã Nhi** dẫn kỵ binh nhẹ và thủy quân truy kích ráo riết hai vua Trần dọc theo hệ thống sông Hồng xuống Nam Định, Ninh Bình.
* **Hành động của quân Trần:**
* Bị ép giữa hai luồng quân (Thoát Hoan phía Bắc, Toa Đô phía Nam), Trần Quốc Tuấn thực hiện một nước cờ cực kỳ táo bạo: Cho hai vua Trần đi thuyền ra vùng biển Quảng Ninh (Hải Đông), rồi từ biển đi ngược vào Thanh Hóa.
* Cú "bẻ lái" này khiến cả Thoát Hoan lẫn Toa Đô hoàn toàn mất dấu mục tiêu. Quân Trần thoát hiểm trong gang tấc và có thời gian nghỉ ngơi, củng cố lực lượng tại Thanh Hóa.
* Lúc này, quân Nguyên bắt đầu cạn kiệt lương thực, không hợp thổ nhưỡng mùa hè oi bức, dịch bệnh phát sinh.
---
### GIAI ĐOẠN 3: TỔNG PHẢN CÔNG THẦN TỐC (Tháng 5 Tháng 6/1285)
Khi thế giặc đã suy, Trần Hưng Đạo phát lệnh tổng phản công. Quân Trần chia làm nhiều mũi, bẻ gãy từng đạo quân địch.
#### Đầu tháng 5/1285: Trận Hàm Tử (Bẻ gãy cánh quân Toa Đô)
* **Hành động của quân Trần:** **Trần Nhật Duật** (Tổng chỉ huy) cùng **Trần Quốc Toản** dẫn một đạo quân thủy bộ tinh nhuệ ngược dòng sông Hồng, chặn đánh đạo quân của Toa Đô tại bến Hàm Tử (Khoái Châu, Hưng Yên).
* **Diễn biến & Kết quả:** Quân Trần phối hợp với lực lượng người Tống (vốn tị nạn ở Đại Việt và căm thù quân Mông Cổ) đánh tan tác thủy quân Toa Đô. Toa Đô đại bại, phải lui quân về đóng giữ ở Tây Kết.
#### Giữa tháng 5/1285: Trận Chương Dương (Giải phóng Thăng Long)
* **Hành động của quân Trần:** Thừa thắng, **Trần Quang Khải** cùng **Trần Quốc Toản** chỉ huy đại quân tiến công thẳng vào căn cứ thủy quân đầu não của địch ở bến Chương Dương (Thường Tín, Hà Nội) - lá chắn bảo vệ kinh thành của Thoát Hoan.
* **Diễn biến & Kết quả:** Quân Trần dùng hỏa công thiêu rụi chiến thuyền giặc. Căn cứ Chương Dương sụp đổ, quân Trần thừa thế tiến vào giải phóng kinh thành Thăng Long. Thoát Hoan hoảng sợ, phải rút chạy sang bờ Bắc sông Hồng (vùng Bắc Ninh ngày nay) để cố thủ.
#### Cuối tháng 5 - Đầu tháng 6/1285: Trận Tây Kết & Sông Cầu (Quét sạch giặc)
Giai đoạn truy kích và tận diệt, hai đạo quân lớn của nhà Nguyên hoàn toàn tan rã.
* **Mặt trận phía Nam (Diệt Toa Đô):**
* **Hành động của quân Trần:** Hai vua Trần và Trần Quốc Tuấn thân chinh dẫn đại quân vây quét Toa Đô tại Tây Kết một lần nữa.
* **Kết quả:** Quân Nguyên đại bại hoàn toàn. **Toa Đô bị chém đầu tại trận**, tướng **Ô Mã Nhi** phải chui vào một chiếc thuyền thúng, trốn ra biển thoát thân.
* **Mặt trận phía Bắc (Truy kích Thoát Hoan):**
* **Hành động của quân Trần:** Trần Quốc Tuấn tung các đạo quân mai phục sẵn ở các cửa ải, bờ sông chặn đường rút chạy của Thoát Hoan về Trung Quốc. **Nguyễn Khoái** dẫn quân phục kích tại sông Cầu và Vạn Kiếp.
* **Kết quả:** Quân Nguyên rơi vào ổ phục kích, thây chất đầy đồng. Tướng Nguyên là **Lý Hằng** trúng tên độc tử trận. Tổng tư lệnh **Thoát Hoan** hoảng loạn đến mức phải chui vào một chiếc **ống đồng** đặt trên xe, bắt quân lính khiêng chạy bán sống bán chết qua biên giới để tránh tên độc của quân Trần.
---
## 6. Kết Quả Và Ý Nghĩa Lịch Sử
* Cuộc kháng chiến của quân dân Đại Việt dưới sự lãnh đạo của hai vua Trần Thánh Tông và Nhân Tông đã toàn thắng, thể hiện sâu sắc **"Hào khí Đông A"** của dân tộc Đại Việt thời ấy.
* Nhà Trần lần thứ hai đánh đuổi thành công quân xâm lược Mông Nguyên, lần này với quy mô lớn hơn nhiều và trong hoàn cảnh khó khăn hơn rất nhiều:
* Nhà Tống ở phương Bắc đã mất hoàn toàn, không còn lá chắn phòng thủ từ xa, Đại Việt phải trực tiếp đối đầu với nhà Nguyên trên toàn tuyến biên giới phía Bắc.
* Sau khi diệt được Nam Tống, sức mạnh tiềm lực kinh tế lẫn quân sự của nhà Nguyên tăng lên vượt bậc so với cuộc chiến lần thứ nhất.