|
| 1 | +import { Feature } from "ol"; |
| 2 | +import { Coordinate } from "ol/coordinate"; |
| 3 | +import { LineString, Polygon, Geometry } from "ol/geom"; |
| 4 | +import { GeoserviceFeatureTypeProp } from "@/constants/communities/types"; |
| 5 | +import { COORD_EPSILON } from "@/constants"; |
| 6 | + |
| 7 | +const NODE_MATCH_MARGIN = COORD_EPSILON * 1000; |
| 8 | + |
| 9 | +function coordDist(a: Coordinate, b: Coordinate): number { |
| 10 | + return Math.hypot(a[0] - b[0], a[1] - b[1]); |
| 11 | +} |
| 12 | + |
| 13 | +function findCoordIndex(coords: Coordinate[], target: Coordinate): number { |
| 14 | + let bestIdx = -1; |
| 15 | + let bestDist = NODE_MATCH_MARGIN; |
| 16 | + for (let i = 0; i < coords.length; i++) { |
| 17 | + const dist = coordDist(coords[i], target); |
| 18 | + if (dist < bestDist) { |
| 19 | + bestDist = dist; |
| 20 | + bestIdx = i; |
| 21 | + } |
| 22 | + } |
| 23 | + return bestIdx; |
| 24 | +} |
| 25 | + |
| 26 | +// Find shared edge: returns indices of shared vertices in both rings |
| 27 | +function findSharedEdge(ring1: Coordinate[], ring2: Coordinate[]): { sharedR1: number[]; sharedR2: number[] } | null { |
| 28 | + const r1 = ring1.slice(0, -1); |
| 29 | + const r2 = ring2.slice(0, -1); |
| 30 | + const n1 = r1.length; |
| 31 | + |
| 32 | + const sharedR1: number[] = []; |
| 33 | + const sharedR2: number[] = []; |
| 34 | + |
| 35 | + for (let i = 0; i < n1; i++) { |
| 36 | + const idx2 = findCoordIndex(r2, r1[i]); |
| 37 | + if (idx2 !== -1) { |
| 38 | + sharedR1.push(i); |
| 39 | + sharedR2.push(idx2); |
| 40 | + } |
| 41 | + } |
| 42 | + |
| 43 | + if (sharedR1.length < 2) return null; |
| 44 | + |
| 45 | + return { sharedR1, sharedR2 }; |
| 46 | +} |
| 47 | + |
| 48 | +export function mergeAdjacentPolygonRings(ring1: Coordinate[], ring2: Coordinate[]): Coordinate[] | null { |
| 49 | + const shared = findSharedEdge(ring1, ring2); |
| 50 | + if (!shared) return null; |
| 51 | + |
| 52 | + const r1 = ring1.slice(0, -1); |
| 53 | + const r2 = ring2.slice(0, -1); |
| 54 | + const n1 = r1.length; |
| 55 | + const n2 = r2.length; |
| 56 | + |
| 57 | + const r1SharedSet = new Set(shared.sharedR1); |
| 58 | + const r2SharedSet = new Set(shared.sharedR2); |
| 59 | + |
| 60 | + // Find transition point |
| 61 | + let transitionIdx1 = -1; |
| 62 | + for (let i = 0; i < n1; i++) { |
| 63 | + if (r1SharedSet.has(i) && !r1SharedSet.has((i + 1) % n1)) { |
| 64 | + transitionIdx1 = i; |
| 65 | + break; |
| 66 | + } |
| 67 | + } |
| 68 | + if (transitionIdx1 === -1) return null; |
| 69 | + |
| 70 | + // Find where we re-enter shared |
| 71 | + let reentryIdx1 = (transitionIdx1 + 1) % n1; |
| 72 | + while (!r1SharedSet.has(reentryIdx1)) { |
| 73 | + reentryIdx1 = (reentryIdx1 + 1) % n1; |
| 74 | + if (reentryIdx1 === transitionIdx1) return null; |
| 75 | + } |
| 76 | + |
| 77 | + const r1Exclusive: Coordinate[] = []; |
| 78 | + let i = (transitionIdx1 + 1) % n1; |
| 79 | + while (i !== reentryIdx1) { |
| 80 | + r1Exclusive.push(r1[i]); |
| 81 | + i = (i + 1) % n1; |
| 82 | + } |
| 83 | + |
| 84 | + // Find corresponding indices |
| 85 | + const transitionCoord = r1[transitionIdx1]; |
| 86 | + const reentryCoord = r1[reentryIdx1]; |
| 87 | + const transitionIdx2 = findCoordIndex(r2, transitionCoord); |
| 88 | + const reentryIdx2 = findCoordIndex(r2, reentryCoord); |
| 89 | + if (transitionIdx2 === -1 || reentryIdx2 === -1) return null; |
| 90 | + |
| 91 | + let dir: 1 | -1 = r2SharedSet.has((reentryIdx2 + 1 + n2) % n2) ? -1 : 1; |
| 92 | + |
| 93 | + const collectR2Exclusive = (d: 1 | -1): Coordinate[] => { |
| 94 | + const out: Coordinate[] = []; |
| 95 | + let j = (reentryIdx2 + d + n2) % n2; |
| 96 | + let safety = 0; |
| 97 | + while (j !== transitionIdx2 && safety < n2) { |
| 98 | + if (!r2SharedSet.has(j)) out.push(r2[j]); |
| 99 | + j = (j + d + n2) % n2; |
| 100 | + safety++; |
| 101 | + } |
| 102 | + return out; |
| 103 | + }; |
| 104 | + |
| 105 | + let r2Exclusive = collectR2Exclusive(dir); |
| 106 | + if (r2Exclusive.length === 0) { |
| 107 | + dir = (dir * -1) as 1 | -1; |
| 108 | + r2Exclusive = collectR2Exclusive(dir); |
| 109 | + } |
| 110 | + |
| 111 | + // Build merged ring: transition vertex -> r1 exclusive -> reentry vertex -> r2 exclusive -> close |
| 112 | + const merged: Coordinate[] = [transitionCoord, ...r1Exclusive, reentryCoord, ...r2Exclusive]; |
| 113 | + merged.push(merged[0]); |
| 114 | + return merged.length >= 4 ? merged : null; |
| 115 | +} |
| 116 | + |
| 117 | +type EndpointPairType = "end1-start2" | "end1-end2" | "start1-start2" | "start1-end2"; |
| 118 | + |
| 119 | +interface EndpointPair { |
| 120 | + type: EndpointPairType; |
| 121 | + dist: number; |
| 122 | +} |
| 123 | + |
| 124 | +function findClosestEndpointPair(coords1: Coordinate[], coords2: Coordinate[]): EndpointPair | null { |
| 125 | + const end1 = coords1[coords1.length - 1]; |
| 126 | + const start1 = coords1[0]; |
| 127 | + const end2 = coords2[coords2.length - 1]; |
| 128 | + const start2 = coords2[0]; |
| 129 | + |
| 130 | + const candidates: EndpointPair[] = [ |
| 131 | + { type: "end1-start2", dist: coordDist(end1, start2) }, |
| 132 | + { type: "end1-end2", dist: coordDist(end1, end2) }, |
| 133 | + { type: "start1-start2", dist: coordDist(start1, start2) }, |
| 134 | + { type: "start1-end2", dist: coordDist(start1, end2) }, |
| 135 | + ]; |
| 136 | + |
| 137 | + const best = candidates.reduce((min, candidate) => (candidate.dist < min.dist ? candidate : min)); |
| 138 | + if (best.dist >= NODE_MATCH_MARGIN) return null; |
| 139 | + return best; |
| 140 | +} |
| 141 | + |
| 142 | +export function mergeLineCoordinates(coords1: Coordinate[], coords2: Coordinate[]): Coordinate[] | null { |
| 143 | + if (coords1.length < 2 || coords2.length < 2) return null; |
| 144 | + |
| 145 | + const pair = findClosestEndpointPair(coords1, coords2); |
| 146 | + if (!pair) return null; |
| 147 | + |
| 148 | + switch (pair.type) { |
| 149 | + case "end1-start2": |
| 150 | + return [...coords1, ...coords2.slice(1)]; |
| 151 | + case "end1-end2": |
| 152 | + return [...coords1, ...[...coords2].reverse().slice(1)]; |
| 153 | + case "start1-start2": |
| 154 | + return [...[...coords1].reverse(), ...coords2.slice(1)]; |
| 155 | + case "start1-end2": |
| 156 | + return [...coords2, ...coords1.slice(1)]; |
| 157 | + } |
| 158 | +} |
| 159 | + |
| 160 | +export function mergeFeatureGeometries(feat1: Feature, feat2: Feature, featureType: GeoserviceFeatureTypeProp): Geometry | null { |
| 161 | + if (featureType === GeoserviceFeatureTypeProp.LINE) { |
| 162 | + const geom1 = feat1.getGeometry() as LineString; |
| 163 | + const geom2 = feat2.getGeometry() as LineString; |
| 164 | + if (!geom1 || !geom2) return null; |
| 165 | + |
| 166 | + const merged = mergeLineCoordinates(geom1.getCoordinates(), geom2.getCoordinates()); |
| 167 | + return merged ? new LineString(merged) : null; |
| 168 | + } |
| 169 | + |
| 170 | + if (featureType === GeoserviceFeatureTypeProp.POLYGON) { |
| 171 | + const geom1 = feat1.getGeometry() as Polygon; |
| 172 | + const geom2 = feat2.getGeometry() as Polygon; |
| 173 | + if (!geom1 || !geom2) return null; |
| 174 | + |
| 175 | + const ring1 = geom1.getLinearRing(0)?.getCoordinates(); |
| 176 | + const ring2 = geom2.getLinearRing(0)?.getCoordinates(); |
| 177 | + if (!ring1 || !ring2) return null; |
| 178 | + |
| 179 | + const merged = mergeAdjacentPolygonRings(ring1, ring2); |
| 180 | + return merged ? new Polygon([merged]) : null; |
| 181 | + } |
| 182 | + |
| 183 | + return null; |
| 184 | +} |
0 commit comments