"use strict"; const fs = require("fs"); const { convertGeoJson, boundsOf } = require("./gaode-junction-reference"); const metricsCache = new WeakMap(); const CORNER_FILLET_SEGMENTS = 12; // Must match DEFAULT_SIDEWALK_WIDTH_METERS in native-road.js so the corner band // lines up with the straight strips it joins. const SIDEWALK_WIDTH_METERS = 2; // The straight strips are trimmed against the cluster boundary using the road // centerline, so they stop a little beyond the carriageway end. Run the corner // past that end and let the two overlap rather than chase an exact seam. const SIDEWALK_CORNER_OVERRUN_METERS = 6; function buildComplexJunctionGeometry(model, cluster, helpers) { const nodeIds = new Set(cluster.nodeIds.map(String)); const nodes = cluster.nodeIds.map((id) => helpers.junctionPlans.get(String(id))?.node).filter(Boolean); if (nodes.length < 2) return { features: [], diagnostics: [helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-insufficient-nodes", "复合路口至少需要两个有效节点。", null)] }; const center = nodes.reduce((sum, point) => [sum[0] + point[0] / nodes.length, sum[1] + point[1] / nodes.length], [0, 0]); const approaches = []; const carriageways = []; for (const [nodeId, plan] of helpers.junctionPlans) { if (!nodeIds.has(String(nodeId))) continue; for (const approach of plan.approaches) { const endpoint = approach.line.at(-1); if (nodes.some((node) => node !== plan.node && helpers.distanceMeters(endpoint, node) < 4)) continue; const heading = helpers.headingAtEndpoint(approach.line); const length = helpers.lineLengthMeters(approach.line); carriageways.push({ nodeId, approach, plan, heading, length }); if (approaches.some((item) => Math.abs(normalizeHeading(item.heading - heading)) < 20)) continue; approaches.push({ nodeId, approach, plan, heading, length }); } } if (approaches.length < 3) return { features: [], diagnostics: [helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-insufficient-approaches", "复合路口无法识别足够的外部进口。", center)] }; const { calibration, coreRadius } = complexJunctionMetrics(cluster); const sorted = [...approaches].sort((a, b) => a.heading - b.heading); const arms = sorted.map((representative) => ({ representative, heading: averageHeading(carriageways.filter((candidate) => Math.abs(normalizeHeading(candidate.heading - representative.heading)) < 20).map((candidate) => candidate.heading)), members: carriageways.filter((candidate) => Math.abs(normalizeHeading(candidate.heading - representative.heading)) < 20), })); const outerRadius = complexJunctionMetrics(cluster).approachOuterRadius; const boundaryParts = []; const crosswalks = []; const stopLines = []; const islands = []; const armCrosswalkRadius = coreRadius * .68; for (const item of carriageways) { const outer = pointOnCarriagewayRadius(item, center, outerRadius, helpers); const inner = pointOnCarriagewayRadius(item, center, coreRadius * .7, helpers); const outerHalf = item.approach.widthMeters / 2; const innerHalf = outerHalf; boundaryParts.push({ item, outer, inner, outerHalf, innerHalf }); const incomingRoad = item.approach.roadIds.map((roadId) => model.roads.find((road) => road.id === roadId)).find((road) => String(road?.sourceNodeIds.at(-1)) === String(item.nodeId)); if (incomingRoad) { // Keep the stop bar just outside the road crosswalk. The previous fixed // core-radius offset placed it nearly ten metres beyond the crossing. const stopCenter = pointOnCarriagewayRadius(item, center, armCrosswalkRadius + 3, helpers); const ring = [ helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf), item.heading, -.24), helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, outerHalf), item.heading, -.24), helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, outerHalf), item.heading, .24), helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf), item.heading, .24), helpers.offsetCoordinate(helpers.offsetCoordinate(stopCenter, item.heading + 90, -outerHalf), item.heading, -.24), ]; stopLines.push({ type: "Feature", properties: { native_id: `complex-stop-line:${cluster.id}:${item.approach.segmentId}`, cluster_id: cluster.id, kind: "complex-stop-line", road_id: incomingRoad.id, node_id: item.nodeId, direction: item.heading, provenance: "native-road-complex-junction-stop-line/v1" }, geometry: { type: "Polygon", coordinates: [ring] } }); } } // The four support lines provide a common corner frame, but each long // crossing remains clipped to its OSM-derived road envelope. Corner islands // fill the remaining frame gaps; crossings must never do that job. for (const arm of arms) { const envelope = armEnvelopeAtRadius(arm, armCrosswalkRadius, center, helpers); if (!envelope) continue; arm.crosswalkFrame = { center: envelope.center, groupDepth: 3.4, supportHeading: normalizeHeading(arm.heading + 90), envelopeWidthMeters: envelope.widthMeters, }; } const frameCorners = arms.map((arm, index) => { const next = arms[(index + 1) % arms.length]; const delta = positiveHeadingDelta(arm.heading, next.heading); if (delta < 45 || delta > 135 || !arm.crosswalkFrame || !next.crosswalkFrame) return null; return supportLineIntersection(arm.crosswalkFrame, next.crosswalkFrame, center); }); for (let index = 0; index < arms.length; index += 1) { const arm = arms[index]; if (!arm.crosswalkFrame) continue; const item = arm.representative; const roadEdgeInset = .35; const usableSpan = Math.max(.42, arm.crosswalkFrame.envelopeWidthMeters - roadEdgeInset * 2); const endpoints = [ helpers.offsetCoordinate(arm.crosswalkFrame.center, arm.heading + 90, -usableSpan / 2), helpers.offsetCoordinate(arm.crosswalkFrame.center, arm.heading + 90, usableSpan / 2), ]; const groupDepth = arm.crosswalkFrame.groupDepth; const stripeWidth = .42; const stripeCount = Math.max(6, Math.floor((usableSpan - stripeWidth) / .82) + 1); const stripeSpacing = stripeCount > 1 ? (usableSpan - stripeWidth) / (stripeCount - 1) : 0; arm.crosswalkFrame.center = midpoint(...endpoints); arm.crosswalkFrame.endpoints = endpoints; arm.crosswalkFrame.spanMeters = usableSpan; arm.crosswalkFrame.roadEdgeInsetMeters = roadEdgeInset; for (let stripe = 0; stripe < stripeCount; stripe += 1) { const along = stripeWidth / 2 + stripe * stripeSpacing; const centerPoint = helpers.offsetCoordinate(endpoints[0], bearing(...endpoints), along); const ring = [ helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, -groupDepth / 2), arm.heading + 90, -stripeWidth / 2), helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, groupDepth / 2), arm.heading + 90, -stripeWidth / 2), helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, groupDepth / 2), arm.heading + 90, stripeWidth / 2), helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, -groupDepth / 2), arm.heading + 90, stripeWidth / 2), helpers.offsetCoordinate(helpers.offsetCoordinate(centerPoint, arm.heading, -groupDepth / 2), arm.heading + 90, -stripeWidth / 2), ]; crosswalks.push({ type: "Feature", properties: { native_id: `complex-crosswalk:${cluster.id}:${item.nodeId}:${item.approach.segmentId}:${stripe + 1}`, cluster_id: cluster.id, kind: "complex-crosswalk", crossing_node_id: item.nodeId, road_id: item.approach.roadIds[0], direction: arm.heading, radial_distance_m: armCrosswalkRadius, span_m: usableSpan, road_envelope_span_m: arm.crosswalkFrame.envelopeWidthMeters, road_edge_inset_m: roadEdgeInset, group_depth_m: groupDepth, stripe_width_m: stripeWidth, stripe_spacing_m: stripeSpacing, frame_center: arm.crosswalkFrame.center, frame_support_heading: arm.crosswalkFrame.supportHeading, provenance: "native-road-complex-junction-crosswalk/v6-road-clipped" }, geometry: { type: "Polygon", coordinates: [ring] } }); } } // The four arm groups are the sides of one pedestrian frame. Each diagonal // group is anchored at the intersection of its adjacent side support lines, // so all eight groups stay one composition when the OSM arms are skewed. for (let index = 0; index < arms.length; index += 1) { const first = arms[index]; const second = arms[(index + 1) % arms.length]; const delta = positiveHeadingDelta(first.heading, second.heading); if (delta < 45 || delta > 135) continue; if (!first.crosswalkFrame || !second.crosswalkFrame) continue; const bisector = normalizeHeading(first.heading + delta / 2); const frameCorner = frameCorners[index]; if (!frameCorner) continue; const cornerStripeSpacing = .62; const cornerStripeWidth = .4; const cornerGroupHalfDepth = (5 * cornerStripeSpacing + cornerStripeWidth) / 2; const endpointForCorner = (arm) => [...arm.crosswalkFrame.endpoints].sort((a, b) => helpers.distanceMeters(a, frameCorner) - helpers.distanceMeters(b, frameCorner))[0]; const outerEdgeAtCorner = (arm) => { const endpoint = endpointForCorner(arm); return [arm.heading, arm.heading + 180] .map((heading) => helpers.offsetCoordinate(endpoint, heading, arm.crosswalkFrame.groupDepth / 2)) .sort((a, b) => directionalProjectionMeters(center, b, bisector) - directionalProjectionMeters(center, a, bisector))[0]; }; const islandBaseGap = .05; const islandApexOffset = 1.5; const islandBase = [outerEdgeAtCorner(first), outerEdgeAtCorner(second)].map((point) => helpers.offsetCoordinate(point, bisector, islandBaseGap)); const islandApex = helpers.offsetCoordinate(frameCorner, bisector, islandApexOffset); const islandCrossingClearance = .2; const cornerCrossingOffset = islandApexOffset + islandCrossingClearance + cornerGroupHalfDepth; const islandInnerRadius = Math.min(...islandBase.map((point) => directionalProjectionMeters(center, point, bisector))); const islandOuterRadius = directionalProjectionMeters(center, islandApex, bisector); const cornerCrossingCenter = helpers.offsetCoordinate(frameCorner, bisector, cornerCrossingOffset); const islandRing = roundedPolygonRing([islandBase[0], islandApex, islandBase[1]], .24); islands.push({ type: "Feature", properties: { native_id: `complex-corner-island:${cluster.id}:${index + 1}`, cluster_id: cluster.id, kind: "complex-corner-island", corner_index: index + 1, from_heading: first.heading, to_heading: second.heading, frame_corner: frameCorner, base_points: islandBase, apex_point: islandApex, inner_radius_m: islandInnerRadius, outer_radius_m: islandOuterRadius, base_width_m: helpers.distanceMeters(...islandBase), crossing_clearance_m: islandCrossingClearance, corner_rounding_ratio: .24, provenance: "native-road-complex-junction-corner/v7-road-gap-fill" }, geometry: { type: "Polygon", coordinates: [islandRing] } }); let cornerCrossingHalfSpan = .4; for (let stripe = 0; stripe < 6; stripe += 1) { const stripeOffset = (stripe - 2.5) * cornerStripeSpacing; const stripeCenter = helpers.offsetCoordinate(cornerCrossingCenter, bisector, stripeOffset); const stripeRadius = directionalProjectionMeters(center, stripeCenter, bisector); const curbPair = limitedCornerPair(first, second, stripeRadius, bisector, center, 6.5, helpers); if (!curbPair) continue; const halfSpan = Math.max(.4, Math.min(6.5, helpers.distanceMeters(...curbPair)) / 2); cornerCrossingHalfSpan = Math.max(cornerCrossingHalfSpan, halfSpan); const stripePair = [ helpers.offsetCoordinate(stripeCenter, bisector - 90, halfSpan), helpers.offsetCoordinate(stripeCenter, bisector + 90, halfSpan), ]; const ring = [ helpers.offsetCoordinate(stripePair[0], bisector, -cornerStripeWidth / 2), helpers.offsetCoordinate(stripePair[1], bisector, -cornerStripeWidth / 2), helpers.offsetCoordinate(stripePair[1], bisector, cornerStripeWidth / 2), helpers.offsetCoordinate(stripePair[0], bisector, cornerStripeWidth / 2), helpers.offsetCoordinate(stripePair[0], bisector, -cornerStripeWidth / 2), ]; crosswalks.push({ type: "Feature", properties: { native_id: `complex-corner-crosswalk:${cluster.id}:${index + 1}:${stripe + 1}`, cluster_id: cluster.id, kind: "complex-corner-crosswalk", corner_index: index + 1, direction: bisector, radial_distance_m: stripeRadius, frame_corner: frameCorner, from_heading: first.heading, to_heading: second.heading, provenance: "native-road-complex-junction-corner-crosswalk/v3" }, geometry: { type: "Polygon", coordinates: [ring] } }); } } // Each carriageway ends in its own rectangle, so adjacent arms meet at a // sharp notch instead of a curb. A real corner is one tangent-continuous // sweep between the two outermost road edges, so fit a fixed-radius fillet // into the wedge those edges form and fill the sector behind it. const cornerFills = []; const sidewalkCorners = []; const cornerDiagnostics = []; const cornerRadius = Math.max(4, Math.min(25, Number(cluster.cornerRadiusMeters) || 12)); for (let index = 0; index < arms.length; index += 1) { const first = arms[index]; const second = arms[(index + 1) % arms.length]; const delta = positiveHeadingDelta(first.heading, second.heading); if (delta < 45 || delta > 135) continue; const bisector = normalizeHeading(first.heading + delta / 2); const edges = [first, second].map((arm) => cornerEdgeAt(arm, coreRadius + 6, bisector, center, helpers)); if (!edges.every(Boolean)) continue; const apex = rayIntersection(edges[0], edges[1], center); const apexReach = apex ? directionalProjectionMeters(center, apex, bisector) : null; // The wedge apex has to sit ahead of the core and inside the arm handoff; // outside that band the two edges are near parallel and any fillet fitted // to them would sweep across the carriageways instead of the corner. if (apexReach === null || apexReach < 1 || apexReach > outerRadius) { cornerDiagnostics.push(helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-corner-fillet-fallback", "该夹角的道路边缘切线无法安全构造圆角,已保留直角过渡。", center)); continue; } // Tangent distance for a circle of `cornerRadius` inscribed in a wedge of // opening `delta`, clamped so the tangent points stay on the built arms. const tangentDistance = Math.min(cornerRadius / Math.tan(delta * Math.PI / 360), Math.max(2, outerRadius - apexReach)); const tangents = edges.map((edge) => helpers.offsetCoordinate(apex, edge.heading, tangentDistance)); const curve = quadraticCurve(tangents[0], apex, tangents[1], CORNER_FILLET_SEGMENTS); const ring = [...curve, center, curve[0]]; if (!ring.every((point) => point.every(Number.isFinite))) continue; cornerFills.push({ type: "Feature", properties: { native_id: `complex-corner-fillet:${cluster.id}:${index + 1}`, cluster_id: cluster.id, kind: "complex-corner-fillet", complex_part: "corner-fillet", corner_index: index + 1, from_heading: first.heading, to_heading: second.heading, bisector_heading: bisector, corner_radius_m: cornerRadius, tangent_distance_m: Math.round(tangentDistance * 100) / 100, apex_reach_m: Math.round(apexReach * 100) / 100, provenance: "native-road-complex-junction-corner-fillet/v1" }, geometry: { type: "Polygon", coordinates: [ring] } }); // The straight pedestrian strips are trimmed at the cluster boundary, so // two arms that both carry a footway still meet as two loose ends across // an empty wedge. Bridge them along the curb the fillet already defines. // The corner faces clockwise from `first` and counter-clockwise from // `second`, so each arm must carry the footway on that facing side. if (!armCarriesSidewalk(first, model, true) || !armCarriesSidewalk(second, model, false)) continue; const curb = [ ...edgeRunToRadius(apex, edges[0], tangentDistance, outerRadius + SIDEWALK_CORNER_OVERRUN_METERS, center, helpers).reverse(), ...curve.slice(1, -1), ...edgeRunToRadius(apex, edges[1], tangentDistance, outerRadius + SIDEWALK_CORNER_OVERRUN_METERS, center, helpers), ]; const outerEdge = offsetPolylineAwayFromCenter(curb, center, SIDEWALK_WIDTH_METERS, helpers); const sidewalkRing = [...curb, ...outerEdge.slice().reverse(), curb[0]]; if (!sidewalkRing.every((point) => point.every(Number.isFinite)) || ringSelfIntersects(sidewalkRing)) { cornerDiagnostics.push(helpers.diagnostic("warning", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-sidewalk-corner-fallback", "该夹角的人行道转角几何自交或无效,已跳过,两侧步行带保持断开。", center)); continue; } sidewalkCorners.push({ type: "Feature", properties: { native_id: `complex-sidewalk-corner:${cluster.id}:${index + 1}`, cluster_id: cluster.id, kind: "complex-sidewalk-corner", corner_index: index + 1, from_heading: first.heading, to_heading: second.heading, bisector_heading: bisector, width_m: SIDEWALK_WIDTH_METERS, overrun_m: SIDEWALK_CORNER_OVERRUN_METERS, provenance: "native-road-complex-junction-sidewalk-corner/v1" }, geometry: { type: "Polygon", coordinates: [sidewalkRing] } }); } const corePoints = boundaryParts.flatMap(({ item, inner, innerHalf }) => [helpers.offsetCoordinate(inner, item.heading + 90, innerHalf), helpers.offsetCoordinate(inner, item.heading - 90, innerHalf)]).sort((first, second) => angleAround(center, first) - angleAround(center, second)); const coreRing = roundedPolygonRing(corePoints, .16); const features = [{ type: "Feature", properties: { native_id: `complex-junction:${cluster.id}:core`, cluster_id: cluster.id, kind: "complex-core", complex_part: "core", center, radius_m: coreRadius, configured_radius_m: cluster.coreRadiusMeters, approach_count: approaches.length, carriageway_count: carriageways.length, approach_headings: sorted.map((item) => Math.round(item.heading * 10) / 10), corner_rounding_ratio: .16, provenance: "native-road-complex-junction/v6-rounded-core" }, geometry: { type: "Polygon", coordinates: [coreRing] } }]; for (const { item, outer, inner, outerHalf, innerHalf } of boundaryParts) { const ring = [helpers.offsetCoordinate(outer, item.heading + 90, outerHalf), helpers.offsetCoordinate(inner, item.heading + 90, innerHalf), helpers.offsetCoordinate(inner, item.heading - 90, innerHalf), helpers.offsetCoordinate(outer, item.heading - 90, outerHalf), helpers.offsetCoordinate(outer, item.heading + 90, outerHalf)]; features.push({ type: "Feature", properties: { native_id: `complex-junction:${cluster.id}:carriageway:${item.approach.segmentId}`, cluster_id: cluster.id, kind: "complex-approach", complex_part: "carriageway", heading_deg: item.heading, lane_count: item.approach.roadIds.reduce((sum, roadId) => sum + (model.roads.find((road) => road.id === roadId)?.laneCount || 0), 0), width_m: item.approach.widthMeters, provenance: "native-road-complex-junction/v5" }, geometry: { type: "Polygon", coordinates: [ring] } }); } // Corner fills come last so they overlay the rectangular carriageway ends // they are smoothing; they never replace an OSM-derived road surface. features.push(...cornerFills); // `coreRadiusMeters` is only consulted when there is no reference geometry. // Under calibration the radius comes from the reference span, so a configured // value that silently does nothing has to be reported, not swallowed. const configuredRadiusIgnored = calibration && Number.isFinite(cluster.coreRadiusMeters) && Math.abs(coreRadius - cluster.coreRadiusMeters) > .5; const configurationDiagnostics = configuredRadiusIgnored ? [helpers.diagnostic("info", `junction-cluster:${cluster.id}`, [...nodeIds], "complex-junction-configured-radius-ignored", `已按参考几何校准核心半径为 ${Math.round(coreRadius * 10) / 10} 米,配置的 coreRadiusMeters=${cluster.coreRadiusMeters} 在有参考文件时不生效。`, center)] : []; return { features, islands: [...islands, ...sidewalkCorners], crosswalks, stopLines, center, approaches, diagnostics: [...cornerDiagnostics, ...configurationDiagnostics, helpers.diagnostic("info", `junction-cluster:${cluster.id}`, [...nodeIds], calibration ? "complex-junction-reference-calibrated" : "complex-junction-generated", calibration ? `已使用参考几何校准参数后,由 OSM/native 重新生成 ${approaches.length} 个进口、道路面、中央分隔带、斑马线和停止线。` : `已独立生成 ${approaches.length} 个进口、道路面、中央分隔带、斑马线和停止线。`, center)] }; } function readReferenceCalibration(cluster) { if (!cluster.referenceFile || !fs.existsSync(cluster.referenceFile)) return null; try { const converted = convertGeoJson(JSON.parse(fs.readFileSync(cluster.referenceFile, "utf8"))); const bounds = boundsOf({ features: converted.features.filter((feature) => [1, 2, 3, 4].includes(Number(feature.properties?.type))) }); const lonScale = 111320 * Math.cos(((bounds.minLat + bounds.maxLat) / 2) * Math.PI / 180); return { longSpanMeters: (bounds.maxLon - bounds.minLon) * lonScale, shortSpanMeters: (bounds.maxLat - bounds.minLat) * 111320 }; } catch (_) { return null; } } function complexJunctionMetrics(cluster) { if (metricsCache.has(cluster)) return metricsCache.get(cluster); const calibration = readReferenceCalibration(cluster); const coreRadius = calibration ? Math.max(12, Math.min(24, calibration.shortSpanMeters * .14)) : Math.max(11, Math.min(17, cluster.coreRadiusMeters * .52)); const metrics = { calibration, coreRadius, approachOuterRadius: coreRadius + (Number(cluster.outerRadiusExtraMeters) || 18) }; metricsCache.set(cluster, metrics); return metrics; } function normalizeHeading(value) { return ((value + 180) % 360 + 360) % 360 - 180; } // `arm.heading` points outward from the junction, so the corner clockwise from // it sits at heading+90 and the one counter-clockwise at heading-90. A road's // own sidewalk flags are relative to its digitisation direction, so flip them // whenever the arm runs against that direction. function armCarriesSidewalk(arm, model, cornerIsClockwise) { return arm.members.some((member) => member.approach.roadIds .map((roadId) => model.roads.find((road) => road.id === roadId)) .filter(Boolean) .some((road) => { const outwardIsForward = String(road.sourceNodeIds[0]) === String(member.nodeId); const onClockwiseSide = outwardIsForward ? road.sidewalkRight : road.sidewalkLeft; const onCounterClockwiseSide = outwardIsForward ? road.sidewalkLeft : road.sidewalkRight; return Boolean(cornerIsClockwise ? onClockwiseSide : onCounterClockwiseSide); })); } // Walk outward along a wedge edge from its tangent point until the curb reaches // `targetRadius`, so the corner band overlaps the straight strip it joins. function edgeRunToRadius(apex, edge, tangentDistance, targetRadius, center, helpers) { const points = []; for (let extra = 0; extra <= 40; extra += 2) { const point = helpers.offsetCoordinate(apex, edge.heading, tangentDistance + extra); points.push(point); if (helpers.distanceMeters(point, center) >= targetRadius) break; } return points; } // Offset each vertex along the polyline normal that increases distance from the // junction centre. The curb is star-shaped around that centre, so "farther from // the centre" is a reliable stand-in for "on the pedestrian side". function offsetPolylineAwayFromCenter(points, center, meters, helpers) { return points.map((point, index) => { const previous = points[Math.max(0, index - 1)]; const next = points[Math.min(points.length - 1, index + 1)]; const tangent = previous === next ? 0 : bearing(previous, next); return [tangent + 90, tangent - 90] .map((heading) => helpers.offsetCoordinate(point, heading, meters)) .sort((first, second) => helpers.distanceMeters(second, center) - helpers.distanceMeters(first, center))[0]; }); } function ringSelfIntersects(ring) { const cross = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); const straddles = (p1, p2, p3, p4) => { const d1 = cross(p3, p4, p1); const d2 = cross(p3, p4, p2); const d3 = cross(p1, p2, p3); const d4 = cross(p1, p2, p4); return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)); }; for (let first = 0; first < ring.length - 1; first += 1) { for (let second = first + 2; second < ring.length - 1; second += 1) { if (first === 0 && second === ring.length - 2) continue; if (straddles(ring[first], ring[first + 1], ring[second], ring[second + 1])) return true; } } return false; } function angleAround(center, point) { return Math.atan2(point[1] - center[1], point[0] - center[0]); } function signedLateralMeters(origin, point, heading) { const east = (point[0] - origin[0]) * 111320 * Math.cos(origin[1] * Math.PI / 180); const north = (point[1] - origin[1]) * 111320; const radians = (heading + 90) * Math.PI / 180; return east * Math.sin(radians) + north * Math.cos(radians); } function bearing(first, second) { const east = (second[0] - first[0]) * Math.cos(first[1] * Math.PI / 180); const north = second[1] - first[1]; return Math.atan2(east, north) * 180 / Math.PI; } function midpoint(first, second) { return [(first[0] + second[0]) / 2, (first[1] + second[1]) / 2]; } function averageHeading(headings) { const vector = headings.reduce((sum, heading) => { const radians = heading * Math.PI / 180; return [sum[0] + Math.sin(radians), sum[1] + Math.cos(radians)]; }, [0, 0]); return Math.atan2(vector[0], vector[1]) * 180 / Math.PI; } function positiveHeadingDelta(first, second) { return ((second - first) % 360 + 360) % 360; } function pointOnCarriagewayRadius(item, center, radius, helpers) { const start = item.approach.line[0]; const startRadius = directionalProjectionMeters(center, start, item.heading); return helpers.pointAlongLine(item.approach.line, Math.max(0, Math.min(item.length, radius - startRadius))); } function armEnvelopeAtRadius(arm, radius, center, helpers) { if (!arm.members.length) return null; const centers = arm.members.map((member) => pointOnCarriagewayRadius(member, center, radius, helpers)); const reference = centers[0]; let minimum = Infinity; let maximum = -Infinity; centers.forEach((point, index) => { const lateral = signedLateralMeters(reference, point, arm.heading); const halfWidth = arm.members[index].approach.widthMeters / 2; minimum = Math.min(minimum, lateral - halfWidth); maximum = Math.max(maximum, lateral + halfWidth); }); if (!Number.isFinite(minimum) || maximum - minimum < 1) return null; return { center: helpers.offsetCoordinate(reference, arm.heading + 90, (minimum + maximum) / 2), widthMeters: maximum - minimum }; } function supportLineIntersection(first, second, origin) { const lonScale = 111320 * Math.cos(origin[1] * Math.PI / 180); const toLocal = (point) => [(point[0] - origin[0]) * lonScale, (point[1] - origin[1]) * 111320]; const firstPoint = toLocal(first.center); const secondPoint = toLocal(second.center); const direction = (heading) => { const radians = heading * Math.PI / 180; return [Math.sin(radians), Math.cos(radians)]; }; const firstDirection = direction(first.supportHeading); const secondDirection = direction(second.supportHeading); const denominator = firstDirection[0] * secondDirection[1] - firstDirection[1] * secondDirection[0]; if (Math.abs(denominator) < 1e-6) return null; const delta = [secondPoint[0] - firstPoint[0], secondPoint[1] - firstPoint[1]]; const distanceAlongFirst = (delta[0] * secondDirection[1] - delta[1] * secondDirection[0]) / denominator; const intersection = [firstPoint[0] + firstDirection[0] * distanceAlongFirst, firstPoint[1] + firstDirection[1] * distanceAlongFirst]; return [origin[0] + intersection[0] / lonScale, origin[1] + intersection[1] / 111320]; } function limitedCornerPair(first, second, radius, bisector, center, maxWidth, helpers) { const pair = [cornerEdgeAtRadius(first, radius, bisector, center, helpers), cornerEdgeAtRadius(second, radius, bisector, center, helpers)]; if (!pair.every(Boolean)) return null; const width = helpers.distanceMeters(pair[0], pair[1]); const middle = midpoint(pair[0], pair[1]); const halfWidth = Math.max(.4, Math.min(width, maxWidth) / 2); const acrossHeading = width > .1 ? bearing(pair[0], pair[1]) : bisector + 90; return [helpers.offsetCoordinate(middle, acrossHeading + 180, halfWidth), helpers.offsetCoordinate(middle, acrossHeading, halfWidth)]; } function cornerEdgeAtRadius(arm, radius, bisector, center, helpers) { return cornerEdgeAt(arm, radius, bisector, center, helpers)?.point || null; } function cornerEdgeAt(arm, radius, bisector, center, helpers) { const candidates = arm.members.flatMap((member) => { const point = pointOnCarriagewayRadius(member, center, radius, helpers); const halfWidth = member.approach.widthMeters / 2; return [90, -90].map((side) => ({ point: helpers.offsetCoordinate(point, member.heading + side, halfWidth), heading: member.heading })); }); return candidates.sort((first, second) => directionalProjectionMeters(center, second.point, bisector) - directionalProjectionMeters(center, first.point, bisector))[0] || null; } function rayIntersection(first, second, origin) { const lonScale = 111320 * Math.cos(origin[1] * Math.PI / 180); const toLocal = (point) => [(point[0] - origin[0]) * lonScale, (point[1] - origin[1]) * 111320]; const direction = (heading) => { const radians = heading * Math.PI / 180; return [Math.sin(radians), Math.cos(radians)]; }; const firstPoint = toLocal(first.point); const secondPoint = toLocal(second.point); const firstDirection = direction(first.heading); const secondDirection = direction(second.heading); const denominator = firstDirection[0] * secondDirection[1] - firstDirection[1] * secondDirection[0]; if (Math.abs(denominator) < 1e-4) return null; const delta = [secondPoint[0] - firstPoint[0], secondPoint[1] - firstPoint[1]]; const distanceAlongFirst = (delta[0] * secondDirection[1] - delta[1] * secondDirection[0]) / denominator; const local = [firstPoint[0] + firstDirection[0] * distanceAlongFirst, firstPoint[1] + firstDirection[1] * distanceAlongFirst]; if (!local.every(Number.isFinite)) return null; return [origin[0] + local[0] / lonScale, origin[1] + local[1] / 111320]; } function quadraticCurve(start, control, end, segments) { const result = []; for (let index = 0; index <= segments; index += 1) { const t = index / segments; const u = 1 - t; result.push([u * u * start[0] + 2 * u * t * control[0] + t * t * end[0], u * u * start[1] + 2 * u * t * control[1] + t * t * end[1]]); } return result; } function directionalProjectionMeters(origin, point, heading) { const east = (point[0] - origin[0]) * 111320 * Math.cos(origin[1] * Math.PI / 180); const north = (point[1] - origin[1]) * 111320; const radians = heading * Math.PI / 180; return east * Math.sin(radians) + north * Math.cos(radians); } function smoothClosedRing(vertices) { // Curb-edge candidates can arrive in opposite winding orders when an OSM // carriageway bends slightly. Sort this local corner only around its own // centroid before rounding, avoiding a self-crossing safety island while // keeping the global junction boundary fully OSM-driven. const centroid = vertices.reduce((sum, point) => [sum[0] + point[0] / vertices.length, sum[1] + point[1] / vertices.length], [0, 0]); const ordered = [...vertices].sort((first, second) => Math.atan2(first[1] - centroid[1], first[0] - centroid[0]) - Math.atan2(second[1] - centroid[1], second[0] - centroid[0])); const points = ordered.flatMap((point, index) => { const next = ordered[(index + 1) % ordered.length]; return [interpolateCoordinate(point, next, .18), interpolateCoordinate(point, next, .82)]; }); return [...points, points[0]]; } function roundedPolygonRing(vertices, ratio) { const points = vertices.flatMap((point, index) => { const previous = vertices[(index - 1 + vertices.length) % vertices.length]; const next = vertices[(index + 1) % vertices.length]; return [interpolateCoordinate(previous, point, 1 - ratio), interpolateCoordinate(point, next, ratio)]; }); return [...points, points[0]]; } function interpolateCoordinate(first, second, ratio) { return [first[0] + (second[0] - first[0]) * ratio, first[1] + (second[1] - first[1]) * ratio]; } module.exports = { buildComplexJunctionGeometry, complexJunctionMetrics };