import { DEFAULT_ENTITY_TYPE_ID } from "@/lib/entityTypeOptions"; import type { Change } from "@/lib/editor/draft/editorTypes"; import type { PendingEntityCreate } from "@/lib/editor/session/sessionTypes"; import type { EntitySnapshot } from "@/types/entities"; import type { Feature, FeatureCollection, GeometrySnapshot, LinkScopeSnapshot } from "@/types/geo"; import type { EditorSnapshot, Section } from "@/types/sections"; export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; const snapshot = raw as EditorSnapshot; if ( snapshot.editor_feature_collection && snapshot.editor_feature_collection.type === "FeatureCollection" && Array.isArray(snapshot.editor_feature_collection.features) ) { return snapshot; } return { ...snapshot, editor_feature_collection: undefined, }; } export function buildEditorSnapshot(options: { section: Section; draft: FeatureCollection; changes: Change[]; pendingEntities: PendingEntityCreate[]; previousSnapshot: EditorSnapshot | null; hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean; }): EditorSnapshot { const changedIds = new Set(options.changes.map((change) => String(change.action === "create" ? change.feature.properties.id : change.id) )); const deletedIds = new Set( options.changes .filter((change): change is Extract => change.action === "delete") .map((change) => String(change.id)) ); const currentDraftIds = new Set(options.draft.features.map((feature) => String(feature.properties.id))); const previousFeatures = new globalThis.Map(); for (const feature of options.previousSnapshot?.editor_feature_collection?.features || []) { previousFeatures.set(String(feature.properties.id), feature); if (!currentDraftIds.has(String(feature.properties.id))) { deletedIds.add(String(feature.properties.id)); } } const previousGeometryOps = new globalThis.Map(); for (const item of options.previousSnapshot?.geometries || []) { const id = typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : ""; const operation = item.operation; if (id && operation) previousGeometryOps.set(id, operation); } const pendingEntityIds = new Set(options.pendingEntities.map((entity) => entity.id)); const entityRows = new globalThis.Map(); for (const item of options.previousSnapshot?.entities || []) { const id = typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : ""; if (id) entityRows.set(id, { ...item }); } for (const entity of options.pendingEntities) { entityRows.set(entity.id, { id: entity.id, operation: "create", name: entity.name, slug: entity.slug, description: null, type_id: entity.type_id, status: entity.status, is_deleted: 0, }); } for (const feature of options.draft.features) { for (const entityId of normalizeFeatureEntityIds(feature)) { if (entityRows.has(entityId)) continue; entityRows.set(entityId, { id: entityId, operation: "reference", name: feature.properties.entity_names?.[0] || feature.properties.entity_name || entityId, slug: null, description: null, type_id: feature.properties.entity_type_id || feature.properties.type || DEFAULT_ENTITY_TYPE_ID, status: 1, is_deleted: 0, }); } } const geometries: GeometrySnapshot[] = options.draft.features.map((feature) => { const id = String(feature.properties.id); const previousOperation = previousGeometryOps.get(id); const previousFeature = previousFeatures.get(id); const changedFromPreviousSnapshot = previousFeature ? JSON.stringify(previousFeature) !== JSON.stringify(feature) : false; const operation: GeometrySnapshot["operation"] = previousOperation === "create" ? "create" : !previousFeature && (options.previousSnapshot || !options.hasPersistedFeature(feature.properties.id)) ? "create" : changedIds.has(id) || changedFromPreviousSnapshot ? "update" : "reference"; const bbox = getFeatureBBox(feature); return { id, operation, type: feature.properties.type || getDefaultTypeIdForFeature(feature), draw_geometry: feature.geometry, binding: normalizeFeatureBindingIds(feature), time_start: feature.properties.time_start ?? null, time_end: feature.properties.time_end ?? null, bbox: bbox ? { min_lng: bbox.minLng, min_lat: bbox.minLat, max_lng: bbox.maxLng, max_lat: bbox.maxLat, } : null, is_deleted: 0, }; }); for (const id of deletedIds) { geometries.push({ id, operation: "delete", is_deleted: 1, }); } const linkScopes: LinkScopeSnapshot[] = options.draft.features .map((feature) => ({ geometry_id: String(feature.properties.id), operation: "replace" as const, entity_ids: normalizeFeatureEntityIds(feature), })) .filter((scope) => scope.entity_ids.length > 0); return { schema_version: 1, section: { id: options.section.id, title: options.section.title, }, editor_feature_collection: JSON.parse(JSON.stringify(options.draft)) as FeatureCollection, entities: Array.from(entityRows.values()).map((entity) => { const id = String(entity.id || ""); if (pendingEntityIds.has(id)) return entity; return entity; }), geometries, link_scopes: linkScopes, }; } export function getDefaultTypeIdForFeature(feature: Feature): string { const preset = feature.properties.geometry_preset; if (preset === "line") return "defense_line"; if (preset === "point") return "city"; if (preset === "circle-area") return "war"; if (preset === "polygon") return DEFAULT_ENTITY_TYPE_ID; const geometryType = feature.geometry.type; if (geometryType === "LineString" || geometryType === "MultiLineString") { return "defense_line"; } if (geometryType === "Point" || geometryType === "MultiPoint") { return "city"; } return DEFAULT_ENTITY_TYPE_ID; } export function normalizeFeatureEntityIds(feature: Feature): string[] { const fromArray = Array.isArray(feature.properties.entity_ids) ? feature.properties.entity_ids.filter((id): id is string => typeof id === "string" && id.trim().length > 0) : []; if (fromArray.length) { return uniqueEntityIds(fromArray); } const single = feature.properties.entity_id; if (typeof single === "string" && single.trim().length > 0) { return [single.trim()]; } return []; } export function normalizeFeatureBindingIds(feature: Feature): string[] { const rawBinding = feature.properties.binding; if (!Array.isArray(rawBinding)) return []; return uniqueEntityIds(rawBinding .map((id) => { if (typeof id !== "string" && typeof id !== "number") return ""; return String(id).trim(); }) .filter((id) => id.length > 0)); } export function parseBindingInput(raw: string): string[] { if (!raw.trim().length) return []; return uniqueEntityIds( raw .split(/[,\n]/) .map((item) => item.trim()) .filter((item) => item.length > 0) ); } export function uniqueEntityIds(ids: string[]): string[] { const deduped: string[] = []; const seen = new Set(); for (const rawId of ids) { const id = rawId.trim(); if (!id || seen.has(id)) continue; seen.add(id); deduped.push(id); } return deduped; } function getFeatureBBox(feature: Feature): { minLng: number; minLat: number; maxLng: number; maxLat: number } | null { const points = collectCoordinatePairs(feature.geometry.coordinates); if (!points.length) return null; let minLng = Number.POSITIVE_INFINITY; let minLat = Number.POSITIVE_INFINITY; let maxLng = Number.NEGATIVE_INFINITY; let maxLat = Number.NEGATIVE_INFINITY; for (const [lng, lat] of points) { minLng = Math.min(minLng, lng); minLat = Math.min(minLat, lat); maxLng = Math.max(maxLng, lng); maxLat = Math.max(maxLat, lat); } return { minLng, minLat, maxLng, maxLat }; } function collectCoordinatePairs(value: unknown): Array<[number, number]> { if (!Array.isArray(value)) return []; if ( value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number" && Number.isFinite(value[0]) && Number.isFinite(value[1]) ) { return [[value[0], value[1]]]; } return value.flatMap((item) => collectCoordinatePairs(item)); }