feat: add region and location geometry types and extend editing engine to support line and point modifications
This commit is contained in:
@@ -41,7 +41,6 @@ export function ModeHint({ mode }: { mode: EditorMode }) {
|
||||
<div style={{ marginTop: 6, fontSize: 12, color: "#93c5fd" }}>
|
||||
<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 }}>
|
||||
<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:
|
||||
<ul style={{ paddingLeft: 16, margin: "2px 0 0 0" }}>
|
||||
<li><b>Enter</b>: Lưu hình đã sửa</li>
|
||||
|
||||
@@ -496,8 +496,10 @@ export function buildPathArrowFeatureCollection(fc: FeatureCollection): FeatureC
|
||||
if (!arrowGeometries) {
|
||||
arrowGeometries = [];
|
||||
const coordinateGroups = getLineCoordinateGroups(feature.geometry);
|
||||
const featureType = getFeatureSemanticType(feature);
|
||||
const isRetreat = featureType === "retreat_route";
|
||||
for (const coordinates of coordinateGroups) {
|
||||
const geometry = buildPathArrowGeometry(coordinates);
|
||||
const geometry = buildPathArrowGeometry(coordinates, isRetreat);
|
||||
if (geometry) arrowGeometries.push(geometry);
|
||||
}
|
||||
pathArrowGeometriesCache.set(feature.geometry, arrowGeometries);
|
||||
@@ -528,7 +530,7 @@ export function getFeatureSemanticType(feature: Feature): string | null {
|
||||
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);
|
||||
if (sourceCoords.length < 2) return null;
|
||||
|
||||
@@ -553,54 +555,141 @@ export function buildPathArrowGeometry(coords: [number, number][]): Geometry | n
|
||||
const shoulderWidth = clampNumber(totalLength * 0.1, 10, 100000);
|
||||
const headWidth = shoulderWidth * 2.0;
|
||||
|
||||
const leftBody: ProjectedPoint[] = [];
|
||||
const rightBody: ProjectedPoint[] = [];
|
||||
|
||||
for (let i = 0; i < bodyPoints.length; i += 1) {
|
||||
const point = bodyPoints[i];
|
||||
const normal = normalAt(bodyPoints, 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 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 = {
|
||||
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 ring = [
|
||||
...leftBody,
|
||||
headBaseLeft,
|
||||
{ x: tip.x, y: tip.y },
|
||||
headBaseRight,
|
||||
...rightBody.reverse(),
|
||||
leftBody[0],
|
||||
].map((point) => unprojectLngLat(point, origin, cosOriginLat));
|
||||
if (isRetreatRoute) {
|
||||
// Segmented Arrow (MultiPolygon)
|
||||
const rings: [number, number][][] = [];
|
||||
|
||||
if (ring.length < 4) return null;
|
||||
return {
|
||||
type: "Polygon",
|
||||
coordinates: [ring],
|
||||
};
|
||||
// 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 rightBody: ProjectedPoint[] = [];
|
||||
|
||||
for (let i = 0; i < bodyPoints.length; i += 1) {
|
||||
const point = bodyPoints[i];
|
||||
const normal = normalAt(bodyPoints, 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 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 ring = [
|
||||
...leftBody,
|
||||
headBaseLeft,
|
||||
{ x: tip.x, y: tip.y },
|
||||
headBaseRight,
|
||||
...rightBody.reverse(),
|
||||
leftBody[0],
|
||||
].map((point) => unprojectLngLat(point, origin, cosOriginLat));
|
||||
|
||||
if (ring.length < 4) return null;
|
||||
return {
|
||||
type: "Polygon",
|
||||
coordinates: [ring],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type ProjectedPoint = {
|
||||
@@ -833,11 +922,21 @@ function createFeatureLabelResolver(
|
||||
return (feature) => {
|
||||
const featureId = String(feature.properties.id);
|
||||
const directEntityIds = getFeatureEntityIds(feature);
|
||||
let label: string | null = null;
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -333,8 +333,9 @@ export function useMapInteraction({
|
||||
drawingEngine.cleanup
|
||||
);
|
||||
|
||||
if (allowGeometryEditing) {
|
||||
editingEngineRef.current?.bindEditEvents(map);
|
||||
const editCleanup = editingEngineRef.current?.bindEditEvents(map);
|
||||
if (editCleanup) {
|
||||
mapCleanupFnsRef.current.push(editCleanup);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -187,6 +187,19 @@ export function setupMapLayers(
|
||||
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({
|
||||
id: "edit-shape-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({
|
||||
id: "edit-handles-circle",
|
||||
type: "circle",
|
||||
|
||||
@@ -200,7 +200,7 @@ export function useMapSync({
|
||||
applyRenderDraftToMap(renderDraft, labelContextDraft, selectedFeatureIds);
|
||||
const editingId = editingEngineRef.current?.editingRef?.current?.id;
|
||||
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) {
|
||||
editingEngineRef.current?.clearEditing();
|
||||
}
|
||||
|
||||
@@ -10,15 +10,16 @@ export type EditingHandle = {
|
||||
isCircle?: boolean;
|
||||
circleCenter?: [number, number];
|
||||
circleRadius?: number;
|
||||
geometryType?: "Point" | "LineString" | "Polygon";
|
||||
};
|
||||
|
||||
export type EditingAPI = {
|
||||
beginEditing: (feature: maplibregl.MapGeoJSONFeature) => 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: {
|
||||
mapRef: React.MutableRefObject<maplibregl.Map | null>;
|
||||
onUpdate: (id: string | number, geometry: Geometry) => void;
|
||||
@@ -43,54 +44,79 @@ export function createEditingEngine(options: {
|
||||
(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 editing = editingRef.current;
|
||||
const map = mapRef.current;
|
||||
console.log("updateEditSources: editing:", editing, "map loaded:", map?.isStyleLoaded());
|
||||
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>;
|
||||
|
||||
if (editing.isCircle && editing.circleCenter && editing.circleRadius !== undefined) {
|
||||
const ring = buildCircleRing(editing.circleCenter, editing.circleRadius);
|
||||
const closedRing = [...ring, ring[0]];
|
||||
shape = {
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
geometry: { type: "Polygon", coordinates: [closedRing] },
|
||||
properties: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
const geomType = editing.geometryType || "Polygon";
|
||||
|
||||
// Circle handles: 0 = center, 1 = radius control
|
||||
const radiusHandlePoint = destinationPoint(editing.circleCenter, editing.circleRadius, 90);
|
||||
handles = {
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
if (geomType === "Polygon") {
|
||||
if (editing.isCircle && editing.circleCenter && editing.circleRadius !== undefined) {
|
||||
const ring = buildCircleRing(editing.circleCenter, editing.circleRadius);
|
||||
const closedRing = [...ring, ring[0]];
|
||||
shape = {
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
geometry: { type: "Polygon", coordinates: [closedRing] },
|
||||
properties: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Circle handles: 0 = center, 1 = radius control
|
||||
const radiusHandlePoint = destinationPoint(editing.circleCenter, editing.circleRadius, 90);
|
||||
handles = {
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
geometry: { type: "Point", coordinates: editing.circleCenter },
|
||||
properties: { idx: 0, type: "center" },
|
||||
},
|
||||
{
|
||||
type: "Feature",
|
||||
geometry: { type: "Point", coordinates: radiusHandlePoint },
|
||||
properties: { idx: 1, type: "radius" },
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
const closedRing = [...editing.ring, editing.ring[0]];
|
||||
shape = {
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
geometry: { type: "Polygon", coordinates: [closedRing] },
|
||||
properties: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
handles = {
|
||||
type: "FeatureCollection",
|
||||
features: editing.ring.map((c, idx) => ({
|
||||
type: "Feature",
|
||||
geometry: { type: "Point", coordinates: editing.circleCenter },
|
||||
properties: { idx: 0, type: "center" },
|
||||
},
|
||||
{
|
||||
type: "Feature",
|
||||
geometry: { type: "Point", coordinates: radiusHandlePoint },
|
||||
properties: { idx: 1, type: "radius" },
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
const closedRing = [...editing.ring, editing.ring[0]];
|
||||
geometry: { type: "Point", coordinates: c },
|
||||
properties: { idx },
|
||||
})),
|
||||
};
|
||||
}
|
||||
} else if (geomType === "LineString") {
|
||||
shape = {
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
geometry: { type: "Polygon", coordinates: [closedRing] },
|
||||
geometry: { type: "LineString", coordinates: editing.ring },
|
||||
properties: {},
|
||||
},
|
||||
],
|
||||
@@ -104,6 +130,23 @@ export function createEditingEngine(options: {
|
||||
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);
|
||||
@@ -116,18 +159,33 @@ export function createEditingEngine(options: {
|
||||
if (!editing) return;
|
||||
|
||||
let geometry: Geometry;
|
||||
if (editing.isCircle && editing.circleCenter && editing.circleRadius !== undefined) {
|
||||
const ring = buildCircleRing(editing.circleCenter, editing.circleRadius);
|
||||
const geomType = editing.geometryType || "Polygon";
|
||||
|
||||
if (geomType === "Polygon") {
|
||||
if (editing.isCircle && editing.circleCenter && editing.circleRadius !== undefined) {
|
||||
const ring = buildCircleRing(editing.circleCenter, editing.circleRadius);
|
||||
geometry = {
|
||||
type: "Polygon",
|
||||
coordinates: [[...ring, ring[0]]],
|
||||
circle_center: editing.circleCenter,
|
||||
circle_radius: editing.circleRadius,
|
||||
};
|
||||
} else {
|
||||
geometry = {
|
||||
type: "Polygon",
|
||||
coordinates: [[...editing.ring, editing.ring[0]]],
|
||||
};
|
||||
}
|
||||
} else if (geomType === "LineString") {
|
||||
geometry = {
|
||||
type: "Polygon",
|
||||
coordinates: [[...ring, ring[0]]],
|
||||
circle_center: editing.circleCenter,
|
||||
circle_radius: editing.circleRadius,
|
||||
type: "LineString",
|
||||
coordinates: editing.ring,
|
||||
};
|
||||
} else {
|
||||
// Point
|
||||
geometry = {
|
||||
type: "Polygon",
|
||||
coordinates: [[...editing.ring, editing.ring[0]]],
|
||||
type: "Point",
|
||||
coordinates: editing.ring[0],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -146,19 +204,56 @@ export function createEditingEngine(options: {
|
||||
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-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) => {
|
||||
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 coords = (geom.coordinates?.[0] ?? []) as [number, number][];
|
||||
if (coords.length < 4) return;
|
||||
const type = geom.type;
|
||||
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;
|
||||
|
||||
// remove duplicated closing point
|
||||
const ring = coords.slice(0, -1).map((c) => [c[0], c[1]] as [number, number]);
|
||||
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
|
||||
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 = {
|
||||
id: feature.id ?? feature.properties?.id,
|
||||
ring,
|
||||
@@ -166,7 +261,9 @@ export function createEditingEngine(options: {
|
||||
isCircle,
|
||||
circleCenter: geom.circle_center,
|
||||
circleRadius: geom.circle_radius,
|
||||
geometryType: type,
|
||||
};
|
||||
console.log("beginEditing: initialized editingRef.current:", editingRef.current);
|
||||
setDeleteVertexMode(false);
|
||||
updateEditSources();
|
||||
};
|
||||
@@ -192,8 +289,8 @@ export function createEditingEngine(options: {
|
||||
const idx = Number(feature?.properties?.idx);
|
||||
if (!Number.isInteger(idx)) return;
|
||||
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) {
|
||||
e.originalEvent.stopPropagation();
|
||||
deleteVertex(idx);
|
||||
return;
|
||||
}
|
||||
@@ -240,7 +337,7 @@ export function createEditingEngine(options: {
|
||||
if (!editing) return;
|
||||
if (e.key === "Enter") {
|
||||
finishEditing();
|
||||
} else if (e.key === "Delete" && !editing.isCircle) {
|
||||
} else if (e.key === "Delete" && editing.geometryType !== "Point" && !editing.isCircle) {
|
||||
e.preventDefault();
|
||||
setDeleteVertexMode(!deleteVertexModeRef.current);
|
||||
} 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.
|
||||
const onHandleContextMenu = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
const editing = editingRef.current;
|
||||
if (!editing || editing.isCircle) return;
|
||||
if (!editing || editing.geometryType === "Point" || editing.isCircle) return;
|
||||
e.preventDefault();
|
||||
e.originalEvent.stopPropagation();
|
||||
const feature = e.features?.[0];
|
||||
@@ -281,15 +378,24 @@ export function createEditingEngine(options: {
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
map.getCanvas().addEventListener("mouseleave", onCanvasLeave);
|
||||
|
||||
map.on("remove", () => {
|
||||
const cleanup = () => {
|
||||
map.off("mousedown", "edit-handles-circle", onHandleDown);
|
||||
map.off("contextmenu", "edit-handles-circle", onHandleContextMenu);
|
||||
map.off("mousemove", onHandleMove);
|
||||
map.off("mouseup", stopDragging);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
map.getCanvas().removeEventListener("mouseleave", onCanvasLeave);
|
||||
try {
|
||||
map.getCanvas()?.removeEventListener("mouseleave", onCanvasLeave);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
hideContextMenu();
|
||||
});
|
||||
map.off("remove", cleanup);
|
||||
};
|
||||
|
||||
map.on("remove", cleanup);
|
||||
|
||||
return cleanup;
|
||||
};
|
||||
|
||||
const showHandleContextMenu = (x: number, y: number, idx: number) => {
|
||||
@@ -328,9 +434,16 @@ export function createEditingEngine(options: {
|
||||
};
|
||||
|
||||
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("Thêm đỉnh", () => insertVertexAfter(idx)));
|
||||
if (canInsert) {
|
||||
menu.appendChild(createItem("Thêm đỉnh", () => insertVertexAfter(idx)));
|
||||
}
|
||||
|
||||
document.body.appendChild(menu);
|
||||
contextMenu = menu;
|
||||
@@ -346,7 +459,10 @@ export function createEditingEngine(options: {
|
||||
|
||||
const deleteVertex = (idx: number) => {
|
||||
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;
|
||||
editing.ring.splice(idx, 1);
|
||||
updateEditSources();
|
||||
@@ -354,8 +470,11 @@ export function createEditingEngine(options: {
|
||||
|
||||
const insertVertexAfter = (idx: number) => {
|
||||
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;
|
||||
const isLine = editing.geometryType === "LineString";
|
||||
if (isLine && idx === editing.ring.length - 1) return;
|
||||
|
||||
const current = editing.ring[idx];
|
||||
const next = editing.ring[(idx + 1) % editing.ring.length];
|
||||
const midpoint: [number, number] = [
|
||||
|
||||
@@ -367,10 +367,13 @@ export function initSelect(
|
||||
const canEditGeometry = allowGeometryEditing ? allowGeometryEditing() : true;
|
||||
|
||||
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 (
|
||||
effectiveCount === 1 &&
|
||||
clickedFeature.source === "countries" &&
|
||||
clickedFeature.geometry?.type === "Polygon" &&
|
||||
isEditableType &&
|
||||
onEdit
|
||||
) {
|
||||
const single = clickedFeature;
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
{ "type_key": "country", "geo_type_code": 9, "fixed": true },
|
||||
{ "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": "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: "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: "capital", label: "Capital", groupId: "point", geometryPreset: "point" },
|
||||
{ value: "city", label: "City", groupId: "point", geometryPreset: "point" },
|
||||
|
||||
@@ -19,6 +19,8 @@ import { getCityLayers } from "./geotypes/city";
|
||||
import { getFortificationLayers } from "./geotypes/fortification";
|
||||
import { getRuinLayers } from "./geotypes/ruin";
|
||||
import { getPortLayers } from "./geotypes/port";
|
||||
import { getRegionLayers } from "./geotypes/region";
|
||||
import { getLocationLayers } from "./geotypes/location";
|
||||
import { getLineLabelLayers } from "./shared/lineLabels";
|
||||
import { getPolygonLabelLayers } from "./shared/polygonLabels";
|
||||
|
||||
@@ -42,7 +44,9 @@ export function getAllGeotypeLayers(sourceId: string, pathArrowSourceId?: string
|
||||
...getCityLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||
...getFortificationLayers(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,
|
||||
},
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -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",
|
||||
color: "#94a3b8",
|
||||
strokeColor: "#475569",
|
||||
dasharray: [6, 3],
|
||||
opacity: 0.82,
|
||||
arrowOpacity: 0.68,
|
||||
showLine: false,
|
||||
|
||||
Reference in New Issue
Block a user