3140 lines
129 KiB
JavaScript
3140 lines
129 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { arrowRingsAt, normalizeManeuver } = require('./turn-lane-arrows');
|
|
const { buildComplexJunctionGeometry, complexJunctionMetrics } = require('./complex-junction');
|
|
|
|
const OVERRIDE_SCHEMA = 'native-road-overrides/v1';
|
|
const MOTOR_HIGHWAYS = new Set([
|
|
'motorway',
|
|
'trunk',
|
|
'primary',
|
|
'secondary',
|
|
'tertiary',
|
|
'unclassified',
|
|
'residential',
|
|
'living_street',
|
|
'service',
|
|
]);
|
|
const DEFAULT_WIDTHS = {
|
|
motorway: 12,
|
|
trunk: 10,
|
|
primary: 10,
|
|
secondary: 8,
|
|
tertiary: 7,
|
|
unclassified: 6,
|
|
residential: 6,
|
|
living_street: 5,
|
|
service: 4,
|
|
};
|
|
const DEFAULT_SIDEWALK_WIDTH_METERS = 2;
|
|
const DIRECTION_ARROW_INTERVAL_METERS = 32;
|
|
const DIRECTION_ARROW_ENDPOINT_BUFFER_METERS = 14;
|
|
const STOP_LINE_OFFSET_METERS = 2.7;
|
|
const STOP_LINE_MAX_APPROACH_DISTANCE_METERS = 25;
|
|
const CROSSWALK_JUNCTION_INSET_METERS = 1.5;
|
|
const CROSSWALK_MAX_JUNCTION_INSET_METERS = 4;
|
|
const CENTER_LINE_DASH_LENGTH_METERS = 2;
|
|
const CENTER_LINE_DASH_GAP_METERS = 2;
|
|
const CENTER_LINE_WIDTH_METERS = 0.25;
|
|
const CENTER_LINE_SOLID_OVERLAP_METERS = 0.04;
|
|
const CENTER_LINE_CONTROL_CLEARANCE_METERS = 1;
|
|
const CENTER_LINE_COLORS = new Set(['yellow', 'white']);
|
|
const CENTER_LINE_PATTERNS = new Set(['dashed', 'solid']);
|
|
const CONNECTOR_BOUNDARY_TOLERANCE_METERS = 0.05;
|
|
// A lane centerline is drawn as a hairline, so probe the crossing with a narrow
|
|
// band. Using the full lane width would clip the line metres early.
|
|
const LANE_CENTERLINE_PROBE_WIDTH_METERS = 0.12;
|
|
const JUNCTION_CURVE_SEGMENTS = 8;
|
|
|
|
function parseOsmRoads(xml) {
|
|
const nodes = new Map();
|
|
const crossingNodes = [];
|
|
for (const match of xml.matchAll(/<node\b([^>]*?)(?:\/>|>([\s\S]*?)<\/node>)/g)) {
|
|
const attrs = xmlAttrs(match[1]);
|
|
if (attrs.action === 'delete' || !attrs.id || attrs.lon === undefined || attrs.lat === undefined) continue;
|
|
const coordinate = [Number(attrs.lon), Number(attrs.lat)];
|
|
if (!coordinate.every(Number.isFinite)) continue;
|
|
const id = String(attrs.id);
|
|
const tags = parseTags(match[2] || '');
|
|
nodes.set(id, coordinate);
|
|
if (tags.highway === 'crossing' && !['no', 'none', 'unmarked'].includes(tags['crossing:markings']))
|
|
crossingNodes.push({ id, coordinate, tags });
|
|
}
|
|
const ways = [];
|
|
for (const match of xml.matchAll(/<way\b([^>]*)>([\s\S]*?)<\/way>/g)) {
|
|
const attrs = xmlAttrs(match[1]);
|
|
const body = match[2];
|
|
const tags = parseTags(body);
|
|
if (attrs.action === 'delete' || !MOTOR_HIGHWAYS.has(tags.highway || '')) continue;
|
|
const refs = [...body.matchAll(/<nd\b([^>]*)\/?\s*>/g)].map((item) => xmlAttrs(item[1]).ref).filter(Boolean);
|
|
const coords = refs.map((ref) => nodes.get(String(ref))).filter(Boolean);
|
|
if (coords.length < 2 || coords.length !== refs.length) continue;
|
|
ways.push({ id: String(attrs.id), refs: refs.map(String), coords, tags });
|
|
}
|
|
return { nodes, ways, crossingNodes };
|
|
}
|
|
|
|
function compileRoadModel(xml, overrides) {
|
|
const parsed = parseOsmRoads(xml);
|
|
const diagnostics = [];
|
|
const roads = [];
|
|
const endpoints = [];
|
|
const byNode = new Map();
|
|
const sharedNodeWayIds = new Map();
|
|
for (const way of parsed.ways)
|
|
for (const nodeId of new Set(way.refs)) {
|
|
if (!sharedNodeWayIds.has(nodeId)) sharedNodeWayIds.set(nodeId, new Set());
|
|
sharedNodeWayIds.get(nodeId).add(way.id);
|
|
}
|
|
for (const sourceWay of parsed.ways) {
|
|
const segments = splitWayAtSharedNodes(sourceWay, sharedNodeWayIds);
|
|
for (const way of segments) {
|
|
const directions =
|
|
way.tags.oneway === 'yes' || way.tags.oneway === '1' || way.tags.junction === 'roundabout'
|
|
? ['forward']
|
|
: ['forward', 'backward'];
|
|
for (const direction of directions) {
|
|
const base = roadAttributes(way.tags, direction);
|
|
const id = `road:way/${way.id}${way.segmentIndex === null ? '' : `:segment/${way.segmentIndex}`}:${direction}`;
|
|
const road = {
|
|
id,
|
|
osmWayIds: [way.id],
|
|
segmentId: `segment:way/${way.id}/${way.segmentIndex ?? 0}`,
|
|
sourceRoadId: `road:way/${way.id}:${direction}`,
|
|
direction,
|
|
highway: way.tags.highway,
|
|
centerline: direction === 'forward' ? way.coords : [...way.coords].reverse(),
|
|
sourceNodeIds: direction === 'forward' ? [way.refs[0], way.refs.at(-1)] : [way.refs.at(-1), way.refs[0]],
|
|
tags: way.tags,
|
|
...base,
|
|
appliedOverrideIds: [],
|
|
diagnostics: [],
|
|
};
|
|
applyRoadOverrides(road, overrides, diagnostics);
|
|
roads.push(road);
|
|
for (const side of ['start', 'end']) {
|
|
const nodeId = side === 'start' ? road.sourceNodeIds[0] : road.sourceNodeIds[1];
|
|
const endpoint = {
|
|
id: `endpoint:${road.id}:${side}`,
|
|
roadId: id,
|
|
side,
|
|
nodeId,
|
|
coordinate: side === 'start' ? road.centerline[0] : road.centerline.at(-1),
|
|
direction,
|
|
};
|
|
endpoints.push(endpoint);
|
|
if (!byNode.has(nodeId)) byNode.set(nodeId, []);
|
|
byNode.get(nodeId).push(endpoint);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const connections = resolveConnections(endpoints, byNode, overrides, diagnostics);
|
|
const extent = roadExtent(roads);
|
|
for (const [nodeId, items] of byNode) {
|
|
if (items.length === 1 && distanceToExtentEdgeMeters(items[0].coordinate, extent) > 25) {
|
|
const endpoint = items[0];
|
|
diagnostics.push({
|
|
...diagnostic(
|
|
'warning',
|
|
endpoint.roadId,
|
|
[nodeId],
|
|
'unconnected-interior-road-end',
|
|
'道路在区域内部结束,未连接到其他机动车道路。请确认这是实际断头,还是 OSM 节点尚未连接。',
|
|
endpoint.coordinate,
|
|
),
|
|
endpointId: endpoint.id,
|
|
manualCandidates: nearbyManualCandidates(endpoints, endpoint),
|
|
});
|
|
}
|
|
}
|
|
const crossings = parsed.crossingNodes.map((crossing) => ({
|
|
...crossing,
|
|
osmWayIds: parsed.ways.filter((way) => way.refs.includes(crossing.id)).map((way) => way.id),
|
|
}));
|
|
return { schema: 'native-road-model/v1', roads, endpoints, connections, crossings, diagnostics };
|
|
}
|
|
|
|
function splitWayAtSharedNodes(way, sharedNodeWayIds) {
|
|
const splitIndexes = [0];
|
|
for (let index = 1; index < way.refs.length - 1; index += 1)
|
|
if ((sharedNodeWayIds.get(way.refs[index])?.size || 0) > 1) splitIndexes.push(index);
|
|
splitIndexes.push(way.refs.length - 1);
|
|
if (splitIndexes.length === 2) return [{ ...way, segmentIndex: null }];
|
|
return splitIndexes.slice(1).map((end, index) => {
|
|
const start = splitIndexes[index];
|
|
return {
|
|
...way,
|
|
refs: way.refs.slice(start, end + 1),
|
|
coords: way.coords.slice(start, end + 1),
|
|
segmentIndex: index + 1,
|
|
};
|
|
});
|
|
}
|
|
|
|
function roadExtent(roads) {
|
|
const points = roads.flatMap((road) => road.centerline);
|
|
return {
|
|
minLon: Math.min(...points.map((point) => point[0])),
|
|
maxLon: Math.max(...points.map((point) => point[0])),
|
|
minLat: Math.min(...points.map((point) => point[1])),
|
|
maxLat: Math.max(...points.map((point) => point[1])),
|
|
};
|
|
}
|
|
|
|
function distanceToExtentEdgeMeters(point, extent) {
|
|
const lonScale = 111320 * Math.cos((point[1] * Math.PI) / 180);
|
|
return Math.min(
|
|
(point[0] - extent.minLon) * lonScale,
|
|
(extent.maxLon - point[0]) * lonScale,
|
|
(point[1] - extent.minLat) * 111320,
|
|
(extent.maxLat - point[1]) * 111320,
|
|
);
|
|
}
|
|
|
|
function roadAttributes(tags, direction) {
|
|
const directional = direction === 'forward' ? 'forward' : 'backward';
|
|
const laneTag = tags[`lanes:${directional}`] ?? (tags.oneway === 'yes' ? tags.lanes : null);
|
|
const parsedLanes = positiveInteger(laneTag);
|
|
const totalLanes = positiveInteger(tags.lanes);
|
|
const lanes = parsedLanes || (totalLanes ? Math.max(1, Math.ceil(totalLanes / (tags.oneway === 'yes' ? 1 : 2))) : 1);
|
|
const parsedWidth = positiveNumber(tags.width);
|
|
const forwardLanes = positiveInteger(tags['lanes:forward']);
|
|
const backwardLanes = positiveInteger(tags['lanes:backward']);
|
|
const directionalLaneTotal = forwardLanes && backwardLanes ? forwardLanes + backwardLanes : totalLanes;
|
|
// `width` describes the whole OSM way. A directional road receives its lane
|
|
// share; absent width falls back to a realistic per-lane carriageway width.
|
|
const width = parsedWidth
|
|
? (parsedWidth * lanes) / (directionalLaneTotal || (tags.oneway === 'yes' ? lanes : lanes * 2))
|
|
: lanes * 3.25;
|
|
return {
|
|
laneCount: lanes,
|
|
widthMeters: width,
|
|
sidewalkLeft: sidewalkState(tags, direction, 'left'),
|
|
sidewalkRight: sidewalkState(tags, direction, 'right'),
|
|
provenance: {
|
|
laneCount:
|
|
parsedLanes || totalLanes ? `tag:${parsedLanes ? `lanes:${directional}` : 'lanes'}` : 'inferred:default-lanes',
|
|
widthMeters: parsedWidth ? 'tag:width (按方向车道数分配)' : 'inferred:3.25m-per-lane',
|
|
},
|
|
};
|
|
}
|
|
|
|
function sidewalkState(tags, direction, side) {
|
|
const osmSide = direction === 'forward' ? side : side === 'left' ? 'right' : 'left';
|
|
const value = tags[`sidewalk:${osmSide}`] ?? tags.sidewalk;
|
|
return value === 'both' || value === 'yes' || value === osmSide;
|
|
}
|
|
|
|
function loadOverrides(file) {
|
|
if (!fs.existsSync(file)) return { schema: OVERRIDE_SCHEMA, overrides: [] };
|
|
return validateOverrides(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
}
|
|
|
|
// An override points at an id derived from OSM. Editing the source can retire
|
|
// that id — a way deleted, or split differently so `segment/6` no longer
|
|
// exists — which leaves the entry pointing at nothing. That is stale data, not
|
|
// a malformed override, so callers that merely consume overrides can ask to
|
|
// skip them and keep going. Callers that *save* overrides still use the default
|
|
// strict mode: writing a reference that cannot resolve is a real error.
|
|
function staleOverrideTarget(item, sets) {
|
|
if (!sets.roadIds) return null;
|
|
if (item.kind === 'road' && typeof item.roadId === 'string' && !sets.roadIds.has(item.roadId)) return item.roadId;
|
|
if (item.kind === 'lane-separator-style' && typeof item.roadId === 'string' && !sets.roadIds.has(item.roadId))
|
|
return item.roadId;
|
|
if (item.kind === 'edge-line-style' && typeof item.roadId === 'string' && !sets.directionalRoadIds.has(item.roadId))
|
|
return item.roadId;
|
|
if (item.kind === 'center-line-style' && typeof item.segmentId === 'string' && !sets.segmentIds.has(item.segmentId))
|
|
return item.segmentId;
|
|
if (
|
|
item.kind === 'junction-connection' &&
|
|
typeof item.fromEndpointId === 'string' &&
|
|
typeof item.toEndpointId === 'string' &&
|
|
(!sets.endpointIds.has(item.fromEndpointId) || !sets.endpointIds.has(item.toEndpointId))
|
|
)
|
|
return `${item.fromEndpointId} → ${item.toEndpointId}`;
|
|
if (
|
|
item.kind === 'lane-connection' &&
|
|
typeof item.fromLaneId === 'string' &&
|
|
typeof item.toLaneId === 'string' &&
|
|
(!sets.laneIds.has(item.fromLaneId) || !sets.laneIds.has(item.toLaneId))
|
|
)
|
|
return `${item.fromLaneId} → ${item.toLaneId}`;
|
|
return null;
|
|
}
|
|
|
|
function validateOverrides(value, model, options = {}) {
|
|
if (!value || value.schema !== OVERRIDE_SCHEMA || !Array.isArray(value.overrides))
|
|
throw new Error(`Overrides must use ${OVERRIDE_SCHEMA}.`);
|
|
const ids = new Set();
|
|
const roadIds = model ? new Set(model.roads.flatMap((road) => [road.id, road.sourceRoadId])) : null;
|
|
const directionalRoadIds = model ? new Set(model.roads.map((road) => road.id)) : null;
|
|
const endpointIds = model ? new Set(model.endpoints.map((endpoint) => endpoint.id)) : null;
|
|
const laneIds = model
|
|
? new Set(
|
|
model.roads.flatMap((road) =>
|
|
Array.from({ length: road.laneCount }, (_, index) => `lane:${road.id}:${index + 1}`),
|
|
),
|
|
)
|
|
: null;
|
|
const segmentIds = model ? new Set(model.roads.map((road) => road.segmentId)) : null;
|
|
const sets = { roadIds, directionalRoadIds, endpointIds, laneIds, segmentIds };
|
|
const kept = [];
|
|
const stale = [];
|
|
for (const item of value.overrides) {
|
|
if (!item || typeof item.id !== 'string' || !item.id || ids.has(item.id))
|
|
throw new Error('Each override needs a unique id.');
|
|
ids.add(item.id);
|
|
if (options.skipStaleTargets) {
|
|
const target = staleOverrideTarget(item, sets);
|
|
if (target) {
|
|
stale.push({ id: item.id, kind: item.kind, target });
|
|
continue;
|
|
}
|
|
}
|
|
if (item.kind === 'road') {
|
|
if (typeof item.roadId !== 'string' || (roadIds && !roadIds.has(item.roadId)))
|
|
throw new Error(`Unknown road override target: ${item.roadId}`);
|
|
for (const key of ['widthMeters', 'laneCount'])
|
|
if (
|
|
item[key] !== undefined &&
|
|
(!Number.isFinite(item[key]) || item[key] <= 0 || (key === 'laneCount' && !Number.isInteger(item[key])))
|
|
)
|
|
throw new Error(`Invalid road override ${key}.`);
|
|
for (const key of ['sidewalkLeft', 'sidewalkRight'])
|
|
if (item[key] !== undefined && typeof item[key] !== 'boolean') throw new Error(`Invalid road override ${key}.`);
|
|
} else if (item.kind === 'junction-connection') {
|
|
if (
|
|
typeof item.fromEndpointId !== 'string' ||
|
|
typeof item.toEndpointId !== 'string' ||
|
|
typeof item.enabled !== 'boolean' ||
|
|
(endpointIds && (!endpointIds.has(item.fromEndpointId) || !endpointIds.has(item.toEndpointId)))
|
|
)
|
|
throw new Error('Invalid junction connection override.');
|
|
if (model && !connectionEndpointsCompatible(model, item.fromEndpointId, item.toEndpointId))
|
|
throw new Error('A manual junction connection must go from a road end to a nearby road start (within 35m).');
|
|
} else if (item.kind === 'lane-connection') {
|
|
if (
|
|
typeof item.fromLaneId !== 'string' ||
|
|
typeof item.toLaneId !== 'string' ||
|
|
typeof item.enabled !== 'boolean' ||
|
|
(laneIds && (!laneIds.has(item.fromLaneId) || !laneIds.has(item.toLaneId)))
|
|
)
|
|
throw new Error('Invalid lane connection override.');
|
|
} else if (item.kind === 'center-line-style') {
|
|
if (
|
|
typeof item.segmentId !== 'string' ||
|
|
!CENTER_LINE_COLORS.has(item.color) ||
|
|
!CENTER_LINE_PATTERNS.has(item.pattern) ||
|
|
(item.double !== undefined && typeof item.double !== 'boolean') ||
|
|
(item.double && (item.color !== 'yellow' || item.pattern !== 'solid')) ||
|
|
(segmentIds && !segmentIds.has(item.segmentId))
|
|
)
|
|
throw new Error('Invalid center line style override.');
|
|
} else if (item.kind === 'lane-separator-style') {
|
|
if (
|
|
typeof item.roadId !== 'string' ||
|
|
(roadIds && !roadIds.has(item.roadId)) ||
|
|
!Number.isInteger(item.leftLaneIndex) ||
|
|
item.rightLaneIndex !== item.leftLaneIndex + 1 ||
|
|
!CENTER_LINE_COLORS.has(item.color) ||
|
|
!CENTER_LINE_PATTERNS.has(item.pattern)
|
|
)
|
|
throw new Error('Invalid lane separator style override.');
|
|
} else if (item.kind === 'edge-line-style') {
|
|
if (
|
|
typeof item.roadId !== 'string' ||
|
|
(directionalRoadIds && !directionalRoadIds.has(item.roadId)) ||
|
|
!['left', 'right'].includes(item.side) ||
|
|
!CENTER_LINE_COLORS.has(item.color) ||
|
|
!CENTER_LINE_PATTERNS.has(item.pattern)
|
|
)
|
|
throw new Error('Invalid edge line style override.');
|
|
} else throw new Error(`Unsupported override kind: ${item.kind}`);
|
|
kept.push(item);
|
|
}
|
|
return { schema: OVERRIDE_SCHEMA, overrides: kept, stale };
|
|
}
|
|
|
|
function applyRoadOverrides(road, overrides, diagnostics) {
|
|
const matching = overrides.overrides.filter(
|
|
(entry) => entry.kind === 'road' && (entry.roadId === road.sourceRoadId || entry.roadId === road.id),
|
|
);
|
|
// A legacy whole-way edit remains the baseline; a segment-specific edit can
|
|
// deliberately refine it after the compiler has introduced split segments.
|
|
matching.sort((first, second) => Number(first.roadId === road.id) - Number(second.roadId === road.id));
|
|
for (const item of matching) {
|
|
for (const key of ['widthMeters', 'laneCount', 'sidewalkLeft', 'sidewalkRight'])
|
|
if (item[key] !== undefined) road[key] = item[key];
|
|
road.appliedOverrideIds.push(item.id);
|
|
for (const key of ['widthMeters', 'laneCount'])
|
|
if (item[key] !== undefined) road.provenance[key] = `override:${item.id}`;
|
|
}
|
|
if (road.widthMeters < road.laneCount * 2.4)
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'warning',
|
|
road.id,
|
|
road.osmWayIds,
|
|
'narrow-lane-width',
|
|
'Configured road width is narrow for the selected lane count.',
|
|
road.centerline[0],
|
|
),
|
|
);
|
|
}
|
|
|
|
function resolveConnections(endpoints, byNode, overrides, diagnostics) {
|
|
const result = [];
|
|
for (const [nodeId, items] of byNode) {
|
|
const arrivals = items.filter((endpoint) => endpoint.side === 'end');
|
|
const departures = items.filter((endpoint) => endpoint.side === 'start');
|
|
for (const arrival of arrivals)
|
|
for (const departure of departures) {
|
|
if (arrival.roadId === departure.roadId) continue;
|
|
const arrivalRoad = endpoints.find((endpoint) => endpoint.id === arrival.id)?.roadId;
|
|
const departureRoad = endpoints.find((endpoint) => endpoint.id === departure.id)?.roadId;
|
|
if (sameOsmWay(endpoints, arrivalRoad, departureRoad)) continue;
|
|
const override = overrides.overrides.find(
|
|
(entry) =>
|
|
entry.kind === 'junction-connection' &&
|
|
entry.fromEndpointId === arrival.id &&
|
|
entry.toEndpointId === departure.id,
|
|
);
|
|
result.push({
|
|
id: `connection:${arrival.id}:${departure.id}`,
|
|
nodeId,
|
|
fromEndpointId: arrival.id,
|
|
toEndpointId: departure.id,
|
|
enabled: override ? override.enabled : true,
|
|
provenance: override ? `override:${override.id}` : 'osm:shared-node',
|
|
});
|
|
}
|
|
if (items.length > 8)
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'warning',
|
|
`junction:node/${nodeId}`,
|
|
[nodeId],
|
|
'complex-junction',
|
|
'Junction has more than eight directional endpoints and is not compiled as an ordinary junction.',
|
|
items[0].coordinate,
|
|
),
|
|
);
|
|
}
|
|
// Overrides can add a deliberate movement omitted by the initial inference.
|
|
// Keep it only when both endpoints still belong to the same OSM junction.
|
|
for (const override of overrides.overrides.filter((item) => item.kind === 'junction-connection')) {
|
|
const exists = result.some(
|
|
(connection) =>
|
|
connection.fromEndpointId === override.fromEndpointId && connection.toEndpointId === override.toEndpointId,
|
|
);
|
|
if (exists) continue;
|
|
const from = endpoints.find((endpoint) => endpoint.id === override.fromEndpointId);
|
|
const to = endpoints.find((endpoint) => endpoint.id === override.toEndpointId);
|
|
if (!from || !to || !connectionEndpointsCompatible({ endpoints }, from.id, to.id)) continue;
|
|
result.push({
|
|
id: `connection:${from.id}:${to.id}`,
|
|
nodeId: from.nodeId,
|
|
fromEndpointId: from.id,
|
|
toEndpointId: to.id,
|
|
enabled: override.enabled,
|
|
provenance: `override:${override.id}`,
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function endpointNode(model, endpointId) {
|
|
return model.endpoints.find((endpoint) => endpoint.id === endpointId)?.nodeId;
|
|
}
|
|
function sameOsmWay(endpoints, firstRoadId, secondRoadId) {
|
|
const roadFor = (roadId) => endpoints.find((endpoint) => endpoint.roadId === roadId)?.roadId;
|
|
const segmentId = (roadId) => roadId.replace(/:(forward|backward)$/, '');
|
|
return segmentId(roadFor(firstRoadId) || firstRoadId) === segmentId(roadFor(secondRoadId) || secondRoadId);
|
|
}
|
|
function connectionEndpointsCompatible(model, fromId, toId) {
|
|
const from = model.endpoints.find((endpoint) => endpoint.id === fromId);
|
|
const to = model.endpoints.find((endpoint) => endpoint.id === toId);
|
|
if (
|
|
!from ||
|
|
!to ||
|
|
from.roadId === to.roadId ||
|
|
sameOsmWay(model.endpoints, from.roadId, to.roadId) ||
|
|
from.side !== 'end' ||
|
|
to.side !== 'start'
|
|
)
|
|
return false;
|
|
if (from.nodeId === to.nodeId) return true;
|
|
const dx = (from.coordinate[0] - to.coordinate[0]) * 111320 * Math.cos((from.coordinate[1] * Math.PI) / 180);
|
|
const dy = (from.coordinate[1] - to.coordinate[1]) * 111320;
|
|
return Math.hypot(dx, dy) <= 35;
|
|
}
|
|
|
|
function nearbyManualCandidates(endpoints, from) {
|
|
return endpoints
|
|
.filter((to) => to.side === 'start' && to.roadId !== from.roadId && !sameOsmWay(endpoints, from.roadId, to.roadId))
|
|
.map((to) => ({ to, distanceMeters: distanceMeters(from.coordinate, to.coordinate) }))
|
|
.filter((item) => item.distanceMeters <= 35)
|
|
.sort((a, b) => a.distanceMeters - b.distanceMeters)
|
|
.slice(0, 3)
|
|
.map(({ to, distanceMeters: meters }) => ({
|
|
toEndpointId: to.id,
|
|
roadId: to.roadId,
|
|
distanceMeters: Math.round(meters * 10) / 10,
|
|
}));
|
|
}
|
|
|
|
// A single physical intersection is often mapped as several nodes joined by
|
|
// short links: a dual carriageway crossing, a slip lane, a staggered junction.
|
|
// Each node then compiles its own surface and the shared area between them is
|
|
// left as ordinary road, which is what produces the width jumps and stray
|
|
// medians around those nodes. Report the clusters so they can be configured as
|
|
// `complex-junction-v1`. Detection is advisory only — it never enables a
|
|
// template or changes geometry, because flipping a junction between the
|
|
// ordinary and complex paths silently on an OSM edit would be unpredictable.
|
|
const COMPLEX_CANDIDATE_MAX_LINK_METERS = 30;
|
|
const COMPLEX_CANDIDATE_MIN_NODES = 2;
|
|
// Short links chain transitively, so a run of closely spaced junctions along
|
|
// one street unions into a single 'cluster' that is really a corridor. A real
|
|
// intersection stays compact, so bound the cluster by its own diameter: the
|
|
// surveyed 珠山湖大道 cluster spans 24.6 m on its four mapped nodes and 40.7 m
|
|
// once the neighbouring service-road junction is folded in.
|
|
const COMPLEX_CANDIDATE_MAX_DIAMETER_METERS = 45;
|
|
|
|
function detectComplexJunctionCandidates(model, junctionPlans, options, diagnostics) {
|
|
const configured = new Set(
|
|
(options.junctionTemplates?.clusters || []).flatMap((cluster) => (cluster.nodeIds || []).map(String)),
|
|
);
|
|
const segmentsByNode = new Map();
|
|
for (const endpoint of model.endpoints) {
|
|
const key = String(endpoint.nodeId);
|
|
if (!segmentsByNode.has(key)) segmentsByNode.set(key, new Set());
|
|
segmentsByNode.get(key).add(endpoint.roadId.replace(/:(forward|backward)$/, ''));
|
|
}
|
|
const junctionNodes = new Set(
|
|
[...segmentsByNode].filter(([, segments]) => segments.size >= 3).map(([nodeId]) => nodeId),
|
|
);
|
|
const parent = new Map();
|
|
const find = (id) => {
|
|
if (!parent.has(id)) parent.set(id, id);
|
|
while (parent.get(id) !== id) {
|
|
parent.set(id, parent.get(parent.get(id)));
|
|
id = parent.get(id);
|
|
}
|
|
return id;
|
|
};
|
|
const union = (first, second) => {
|
|
const a = find(first);
|
|
const b = find(second);
|
|
if (a !== b) parent.set(a, b);
|
|
};
|
|
const links = new Map();
|
|
const seenSegments = new Set();
|
|
for (const road of model.roads) {
|
|
if (seenSegments.has(road.segmentId)) continue;
|
|
seenSegments.add(road.segmentId);
|
|
const start = String(road.sourceNodeIds[0]);
|
|
const end = String(road.sourceNodeIds.at(-1));
|
|
if (start === end || !junctionNodes.has(start) || !junctionNodes.has(end)) continue;
|
|
const length = lineLengthMeters(road.centerline);
|
|
if (length > COMPLEX_CANDIDATE_MAX_LINK_METERS) continue;
|
|
union(start, end);
|
|
links.set(road.segmentId, { start, end, length });
|
|
}
|
|
const clusters = new Map();
|
|
for (const nodeId of parent.keys()) {
|
|
const root = find(nodeId);
|
|
if (!clusters.has(root)) clusters.set(root, []);
|
|
clusters.get(root).push(nodeId);
|
|
}
|
|
for (const nodeIds of clusters.values()) {
|
|
if (nodeIds.length < COMPLEX_CANDIDATE_MIN_NODES) continue;
|
|
if (nodeIds.some((nodeId) => configured.has(nodeId))) continue;
|
|
const points = nodeIds.map((nodeId) => junctionPlans.get(nodeId)?.node).filter(Boolean);
|
|
if (points.length !== nodeIds.length) continue;
|
|
const center = points.reduce(
|
|
(sum, point) => [sum[0] + point[0] / points.length, sum[1] + point[1] / points.length],
|
|
[0, 0],
|
|
);
|
|
const spreadMeters = Math.max(...points.map((point) => distanceMeters(center, point)));
|
|
let diameterMeters = 0;
|
|
for (let first = 0; first < points.length; first += 1) {
|
|
for (let second = first + 1; second < points.length; second += 1)
|
|
diameterMeters = Math.max(diameterMeters, distanceMeters(points[first], points[second]));
|
|
}
|
|
if (diameterMeters > COMPLEX_CANDIDATE_MAX_DIAMETER_METERS) continue;
|
|
const inner = [...links.values()].filter((link) => nodeIds.includes(link.start) && nodeIds.includes(link.end));
|
|
const widths = nodeIds.flatMap((nodeId) =>
|
|
(junctionPlans.get(nodeId)?.approaches || []).map((approach) => approach.widthMeters),
|
|
);
|
|
const widestApproach = widths.length ? Math.max(...widths) : 0;
|
|
// Enough core to cover every member node plus the widest approach's half
|
|
// width, with a little slack. A starting point for tuning, not a result.
|
|
const suggestedCoreRadius = Math.min(80, Math.max(12, Math.round(spreadMeters + widestApproach / 2 + 4)));
|
|
diagnostics.push({
|
|
...diagnostic(
|
|
'info',
|
|
`junction-cluster-candidate:${nodeIds.slice().sort().join('+')}`,
|
|
nodeIds,
|
|
'complex-junction-candidate',
|
|
`检测到 ${nodeIds.length} 个路口节点由 ${inner.length} 条短路段(最长 ${Math.round(Math.max(...inner.map((link) => link.length)) * 10) / 10} 米)相连,可能是同一个物理路口。当前按独立路口编译;如需合并请在 nativeRoad.junctionTemplates.clusters 中配置。`,
|
|
center,
|
|
),
|
|
suggestedCluster: {
|
|
template: 'complex-junction-v1',
|
|
nodeIds: nodeIds.slice().sort(),
|
|
nodeCount: nodeIds.length,
|
|
spreadMeters: Math.round(spreadMeters * 10) / 10,
|
|
diameterMeters: Math.round(diameterMeters * 10) / 10,
|
|
longestLinkMeters: Math.round(Math.max(...inner.map((link) => link.length)) * 10) / 10,
|
|
widestApproachMeters: Math.round(widestApproach * 100) / 100,
|
|
coreRadiusMeters: suggestedCoreRadius,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
function compileGeometry(model, overrides = { overrides: [] }, options = {}) {
|
|
const diagnostics = [...model.diagnostics];
|
|
const junctionPlans = compileJunctionPlans(model, options, diagnostics);
|
|
detectComplexJunctionCandidates(model, junctionPlans, options, diagnostics);
|
|
const features = [];
|
|
const activeClusters = options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : [];
|
|
const clusterByNode = new Map(
|
|
activeClusters.flatMap((cluster) => cluster.nodeIds.map((nodeId) => [String(nodeId), cluster])),
|
|
);
|
|
const complexClusterCenters = new Map(
|
|
activeClusters
|
|
.filter((cluster) => cluster.template === 'complex-junction-v1')
|
|
.map((cluster) => {
|
|
const points = cluster.nodeIds.map((nodeId) => junctionPlans.get(String(nodeId))?.node).filter(Boolean);
|
|
const center = points.length
|
|
? points.reduce(
|
|
(sum, point) => [sum[0] + point[0] / points.length, sum[1] + point[1] / points.length],
|
|
[0, 0],
|
|
)
|
|
: null;
|
|
return [cluster.id, center];
|
|
}),
|
|
);
|
|
const emittedSegments = new Set();
|
|
const generatedComplexSidewalks = [];
|
|
const generatedComplexCrosswalks = [];
|
|
const generatedComplexStopLines = [];
|
|
for (const road of model.roads) {
|
|
const segmentKey = road.segmentId;
|
|
if (emittedSegments.has(segmentKey)) continue;
|
|
emittedSegments.add(segmentKey);
|
|
const directions = model.roads.filter((item) => item.segmentId === segmentKey);
|
|
const startCluster = clusterByNode.get(String(road.sourceNodeIds[0]));
|
|
const endCluster = clusterByNode.get(String(road.sourceNodeIds.at(-1)));
|
|
if (
|
|
startCluster?.template === 'complex-junction-v1' &&
|
|
endCluster?.template === 'complex-junction-v1' &&
|
|
startCluster.id === endCluster.id
|
|
)
|
|
continue;
|
|
const totalWidth = directions.reduce((sum, item) => sum + item.widthMeters, 0);
|
|
// The approach surface stops at the junction cutback. The junction layer
|
|
// owns the intervening rounded corners; leaving approaches untrimmed
|
|
// would cover that outline with rectangular road ends in Blender/Cesium.
|
|
const cluster =
|
|
clusterByNode.get(String(road.sourceNodeIds[0])) || clusterByNode.get(String(road.sourceNodeIds.at(-1)));
|
|
if (cluster?.template === 'complex-junction-v1') {
|
|
const center = complexClusterCenters.get(cluster.id);
|
|
const length = lineLengthMeters(road.centerline);
|
|
const farEndpoint = cluster.nodeIds.map(String).includes(String(road.sourceNodeIds[0]))
|
|
? road.centerline.at(-1)
|
|
: road.centerline[0];
|
|
const outerRadius = complexJunctionMetrics(cluster).approachOuterRadius;
|
|
if (center && length < outerRadius + 8 && distanceMeters(farEndpoint, center) < outerRadius) continue;
|
|
}
|
|
const line =
|
|
cluster?.template === 'complex-junction-v1'
|
|
? trimLineAtComplexCluster(
|
|
road.centerline,
|
|
road.sourceNodeIds,
|
|
junctionPlans,
|
|
cluster,
|
|
complexClusterCenters.get(cluster.id),
|
|
)
|
|
: trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans);
|
|
const ring = roadRing(line, totalWidth);
|
|
if (!ring) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'error',
|
|
road.id,
|
|
road.osmWayIds,
|
|
'unclosed-road-surface',
|
|
'Could not construct a valid road polygon from this centerline.',
|
|
road.centerline[0],
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
const surfaceId = road.segmentId.endsWith('/0')
|
|
? `surface:way/${road.osmWayIds.join(',')}`
|
|
: `surface:${segmentKey}`;
|
|
features.push({
|
|
type: 'Feature',
|
|
properties: {
|
|
native_id: surfaceId,
|
|
cluster_id: cluster?.template === 'complex-junction-v1' ? cluster.id : null,
|
|
directional_road_ids: directions.map((item) => item.id).join(','),
|
|
osm_way_ids: road.osmWayIds.join(','),
|
|
source_road_id: road.sourceRoadId,
|
|
width_m: totalWidth,
|
|
lane_count: directions.reduce((sum, item) => item.laneCount + sum, 0),
|
|
provenance: JSON.stringify(directions.map((item) => item.provenance)),
|
|
override_ids: directions.flatMap((item) => item.appliedOverrideIds).join(','),
|
|
},
|
|
geometry: { type: 'Polygon', coordinates: [ring] },
|
|
});
|
|
}
|
|
for (const [nodeId, plan] of junctionPlans) {
|
|
if (plan.clusterId && activeComplexCluster(options, plan.clusterId)) continue;
|
|
if (!plan.template) continue;
|
|
for (const approach of plan.approaches) {
|
|
const transition = templateApproachRing(approach, plan);
|
|
if (!transition) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'warning',
|
|
`junction:node/${nodeId}`,
|
|
[nodeId],
|
|
'junction-template-approach-fallback',
|
|
'进口道路长度不足以生成规整过渡面,已保留该进口的 native 直筒道路。',
|
|
plan.node,
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
features.push({
|
|
type: 'Feature',
|
|
properties: {
|
|
native_id: `junction-approach:${plan.template}:node/${nodeId}:${approach.segmentId}`,
|
|
osm_node_id: nodeId,
|
|
segment_id: approach.segmentId,
|
|
directional_road_ids: approach.roadIds.join(','),
|
|
width_m: approach.widthMeters,
|
|
approach_width_m: Math.round(approach.widthMeters * plan.approachWidthMultiplier * 10) / 10,
|
|
approach_length_m: Math.round(transition.lengthMeters * 10) / 10,
|
|
template: plan.template,
|
|
provenance: 'native-road-junction-approach-template/v1',
|
|
},
|
|
geometry: { type: 'Polygon', coordinates: [transition.ring] },
|
|
});
|
|
}
|
|
}
|
|
for (const cluster of activeClusters) {
|
|
if (cluster.template !== 'complex-junction-v1') continue;
|
|
const generated = buildComplexJunctionGeometry(model, cluster, {
|
|
junctionPlans,
|
|
diagnostic,
|
|
distanceMeters,
|
|
lineLengthMeters,
|
|
pointAlongLine,
|
|
offsetCoordinate,
|
|
headingAtEndpoint,
|
|
headingVector,
|
|
circleRing,
|
|
});
|
|
features.push(...generated.features);
|
|
if (generated.crosswalks) generatedComplexCrosswalks.push(...generated.crosswalks);
|
|
if (generated.stopLines) generatedComplexStopLines.push(...generated.stopLines);
|
|
if (generated.islands) generatedComplexSidewalks.push(...generated.islands);
|
|
diagnostics.push(...generated.diagnostics);
|
|
}
|
|
const lanes = compileLaneCenterlines(model, diagnostics, junctionPlans, options, {
|
|
crosswalks: generatedComplexCrosswalks,
|
|
stopLines: generatedComplexStopLines,
|
|
});
|
|
const edgeLines =
|
|
options.edgeLines === false ? [] : compileEdgeLines(model, overrides, junctionPlans, options, features);
|
|
const controls = compileControlMarkings(model, lanes, diagnostics, junctionPlans);
|
|
const allControls = {
|
|
crosswalks: [...controls.crosswalks, ...generatedComplexCrosswalks],
|
|
stopLines: [...controls.stopLines, ...generatedComplexStopLines],
|
|
};
|
|
const centerLines = compileCenterLines(model, overrides, junctionPlans, allControls, diagnostics, options);
|
|
const markings = compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans, allControls, options);
|
|
markings.separators.push(
|
|
...compileComplexLaneSeparators(lanes.features, [...allControls.crosswalks, ...allControls.stopLines]),
|
|
);
|
|
markings.directionArrows.push(...compileComplexPreviewArrows(lanes.features, generatedComplexStopLines));
|
|
const sidewalks = compileSidewalkSurfaces(model, diagnostics, junctionPlans, options);
|
|
// Complex crosswalks are generated from native approach tangents; do not
|
|
// synthesize side strips that can be mistaken for crosswalks.
|
|
sidewalks.push(...generatedComplexSidewalks);
|
|
const connectorResult = compileConnectors(model, lanes, diagnostics, overrides, junctionPlans);
|
|
const junctionFeatures = compileJunctionSurfaces(
|
|
model,
|
|
junctionPlans,
|
|
connectorResult.features,
|
|
connectorResult.movements,
|
|
diagnostics,
|
|
options,
|
|
);
|
|
validateConnectorContainment(connectorResult.features, junctionFeatures, diagnostics);
|
|
return {
|
|
roadSurface: { type: 'FeatureCollection', features },
|
|
edgeLines: { type: 'FeatureCollection', features: edgeLines },
|
|
sidewalkSurface: { type: 'FeatureCollection', features: sidewalks },
|
|
intersectionSurface: { type: 'FeatureCollection', features: junctionFeatures },
|
|
laneCenterlines: { type: 'FeatureCollection', features: lanes.features },
|
|
laneSeparators: { type: 'FeatureCollection', features: markings.separators },
|
|
centerLines: { type: 'FeatureCollection', features: centerLines },
|
|
directionArrows: { type: 'FeatureCollection', features: markings.directionArrows },
|
|
turnArrows: { type: 'FeatureCollection', features: markings.turnArrows },
|
|
crosswalks: { type: 'FeatureCollection', features: [...controls.crosswalks, ...generatedComplexCrosswalks] },
|
|
vehicleStopLines: { type: 'FeatureCollection', features: [...controls.stopLines, ...generatedComplexStopLines] },
|
|
connectors: { type: 'FeatureCollection', features: connectorResult.features },
|
|
movements: connectorResult.movements,
|
|
diagnostics,
|
|
};
|
|
}
|
|
|
|
function compileComplexPreviewArrows(features, stopLines) {
|
|
const result = [];
|
|
for (const feature of features) {
|
|
if (
|
|
!feature.properties?.cluster_preview ||
|
|
!feature.properties.incoming ||
|
|
feature.properties.maneuver === 'outbound'
|
|
)
|
|
continue;
|
|
const line = feature.geometry?.coordinates || [];
|
|
if (line.length < 2) continue;
|
|
const stopLine = stopLines.find((candidate) => candidate.properties?.road_id === feature.properties.road_id);
|
|
if (!stopLine) continue;
|
|
const stopRing = stopLine.geometry?.coordinates?.[0];
|
|
if (!stopRing || stopRing.length < 4) continue;
|
|
const stopPoints = stopRing.slice(0, -1);
|
|
const stopCenter = [
|
|
stopPoints.reduce((sum, point) => sum + point[0], 0) / stopPoints.length,
|
|
stopPoints.reduce((sum, point) => sum + point[1], 0) / stopPoints.length,
|
|
];
|
|
const placement = distanceMeters(line.at(-1), stopCenter) + 8;
|
|
const placementInfo = pointAndAxisAlongLine(line, Math.max(0, lineLengthMeters(line) - placement));
|
|
if (!placementInfo) continue;
|
|
const rings = arrowRingsAt(feature.properties.maneuver, placementInfo.point, placementInfo.axis);
|
|
for (let part = 0; part < rings.length; part += 1)
|
|
result.push({
|
|
type: 'Feature',
|
|
properties: {
|
|
native_id: `${feature.properties.native_id}:arrow:${part}`,
|
|
road_id: feature.properties.road_id,
|
|
lane_id: feature.properties.native_id,
|
|
cluster_id: feature.properties.cluster_id,
|
|
cluster_preview: true,
|
|
maneuver: feature.properties.maneuver,
|
|
travel_heading_deg: headingDegrees(line[0], line.at(-1)),
|
|
placement_distance_from_stop_meters: 8,
|
|
provenance: 'native-road-complex-preview-arrow/v2-stop-anchored',
|
|
},
|
|
geometry: { type: 'Polygon', coordinates: [rings[part]] },
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function compileComplexLaneSeparators(features, controls = []) {
|
|
const groups = new Map();
|
|
for (const feature of features) {
|
|
if (!feature.properties?.cluster_preview || !feature.properties.road_id) continue;
|
|
if (!groups.has(feature.properties.road_id)) groups.set(feature.properties.road_id, []);
|
|
groups.get(feature.properties.road_id).push(feature);
|
|
}
|
|
const result = [];
|
|
for (const [roadId, lanes] of groups) {
|
|
lanes.sort((first, second) => first.properties.lane_index - second.properties.lane_index);
|
|
for (let index = 1; index < lanes.length; index += 1) {
|
|
const left = lanes[index - 1].geometry.coordinates;
|
|
const right = lanes[index].geometry.coordinates;
|
|
if (left.length !== right.length) continue;
|
|
const line = left.map((point, pointIndex) => [
|
|
(point[0] + right[pointIndex][0]) / 2,
|
|
(point[1] + right[pointIndex][1]) / 2,
|
|
]);
|
|
const visibleLine = trimLineBeforeFirstControl(line, controls, 0.12);
|
|
const ring = visibleLine ? roadRing(visibleLine, 0.12) : null;
|
|
if (!ring) continue;
|
|
result.push({
|
|
type: 'Feature',
|
|
properties: {
|
|
native_id: `complex-lane-separator:${roadId}:${index}-${index + 1}`,
|
|
road_id: roadId,
|
|
cluster_id: lanes[0].properties.cluster_id,
|
|
left_lane_index: index,
|
|
right_lane_index: index + 1,
|
|
color: 'white',
|
|
pattern: 'solid',
|
|
effective_style: 'white-solid',
|
|
provenance: 'native-road-complex-lane-separator/v1',
|
|
},
|
|
geometry: { type: 'Polygon', coordinates: [ring] },
|
|
});
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function trimLineBeforeFirstControl(line, controls, width) {
|
|
const total = lineLengthMeters(line);
|
|
const step = 0.25;
|
|
for (let distance = step; distance <= total; distance += step) {
|
|
const placement = pointAndAxisAlongLine(line, Math.min(total, distance - step / 2));
|
|
if (!placement) continue;
|
|
const ring = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], step, width, 0);
|
|
if (!ringsOverlapControl([ring], controls)) continue;
|
|
let cutoff = Math.max(0, distance - step - 0.12);
|
|
while (cutoff > 0.5) {
|
|
const candidate = roadRing([line[0], pointAlongLine(line, cutoff)], width);
|
|
if (candidate && !ringsOverlapControl([candidate], controls)) return [line[0], pointAlongLine(line, cutoff)];
|
|
cutoff -= 0.25;
|
|
}
|
|
return null;
|
|
}
|
|
return line;
|
|
}
|
|
|
|
// `trimLineBeforeFirstControl` always keeps the head of the line, so the caller
|
|
// must hand it a line that already runs from the road towards the junction.
|
|
// Lane centerlines arrive in either orientation (a preview lane runs inward
|
|
// from the outer radius, an outgoing road lane runs outward from the cluster
|
|
// node), so orient by radius first and restore the original order afterwards.
|
|
function trimLaneOutsideControls(line, controls, width, center) {
|
|
if (!controls.length || !center || !Array.isArray(line) || line.length < 2) return line;
|
|
const outwardFirst = distanceMeters(line[0], center) >= distanceMeters(line.at(-1), center);
|
|
const oriented = outwardFirst ? line : [...line].reverse();
|
|
const trimmed = trimLineBeforeFirstControl(oriented, controls, width);
|
|
if (!trimmed) return null;
|
|
return outwardFirst ? trimmed : [...trimmed].reverse();
|
|
}
|
|
|
|
// Reference identity is not reliable here: the reversed path rebuilds the array
|
|
// even when nothing was cut. Compare travelled length instead.
|
|
function laneWasClipped(original, visible) {
|
|
return Boolean(visible) && lineLengthMeters(visible) < lineLengthMeters(original) - 0.01;
|
|
}
|
|
|
|
function compileEdgeLines(model, overrides, junctionPlans, options = {}, roadSurfaces = []) {
|
|
const features = [];
|
|
const clusters = options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : [];
|
|
const clusterByNode = new Map(
|
|
clusters.flatMap((cluster) => cluster.nodeIds.map((nodeId) => [String(nodeId), cluster])),
|
|
);
|
|
const clusterCenters = new Map(clusters.map((cluster) => [cluster.id, clusterCenter(cluster, junctionPlans)]));
|
|
// Roads swallowed by a complex cluster never get a surface. Deriving their
|
|
// edge from the centerline anyway paints a curb across bare ground, so take
|
|
// the surfaces actually emitted as the authority on what can be outlined.
|
|
const surfaced = new Set(
|
|
roadSurfaces
|
|
.flatMap((feature) => String(feature.properties?.directional_road_ids || '').split(','))
|
|
.filter(Boolean),
|
|
);
|
|
for (const road of model.roads) {
|
|
if (surfaced.size && !surfaced.has(road.id)) continue;
|
|
// The road surface is one polygon per segment, centred on this centerline
|
|
// and spanning the sum of both directions. Deriving the edge from a single
|
|
// direction's width puts it half a carriageway inside the asphalt.
|
|
const directions = model.roads.filter((item) => item.segmentId === road.segmentId);
|
|
const totalWidth = directions.reduce((sum, item) => sum + item.widthMeters, 0);
|
|
const bidirectional = directions.length > 1;
|
|
// Match the surface's trim exactly. A cluster road is cut at the cluster
|
|
// boundary, not at the ordinary junction cutback; using the cutback here
|
|
// runs the edge line out past the asphalt it is supposed to outline.
|
|
const cluster =
|
|
clusterByNode.get(String(road.sourceNodeIds[0])) || clusterByNode.get(String(road.sourceNodeIds.at(-1)));
|
|
const line =
|
|
cluster?.template === 'complex-junction-v1'
|
|
? trimLineAtComplexCluster(
|
|
road.centerline,
|
|
road.sourceNodeIds,
|
|
junctionPlans,
|
|
cluster,
|
|
clusterCenters.get(cluster.id),
|
|
)
|
|
: trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans);
|
|
// On a two-way segment, the inner edge is the road centre boundary and is
|
|
// owned by center_lines. Emit only each directional carriageway's outer
|
|
// edge; emitting both sides makes the layer look like a second centreline.
|
|
const offsets = bidirectional ? [-1] : [-1, 1];
|
|
for (const offset of offsets) {
|
|
const side = offset < 0 ? 'right' : 'left';
|
|
const style = edgeLineStyle(overrides, road.id, side);
|
|
const centerline = offsetLine(line, (offset * totalWidth) / 2);
|
|
if (!centerline) continue;
|
|
if (style.pattern === 'solid') {
|
|
const ring = roadRing(centerline, 0.12);
|
|
if (ring) features.push(edgeLineFeature(road, side, style, ring));
|
|
continue;
|
|
}
|
|
for (let distance = 1, part = 1; distance + 1 <= lineLengthMeters(centerline); distance += 4, part += 1) {
|
|
const placement = pointAndAxisAlongLine(centerline, distance);
|
|
if (!placement) continue;
|
|
features.push(
|
|
edgeLineFeature(
|
|
road,
|
|
side,
|
|
style,
|
|
rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], 2, 0.12, 0),
|
|
part,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return features;
|
|
}
|
|
|
|
function edgeLineFeature(road, side, style, ring, part = null) {
|
|
return {
|
|
type: 'Feature',
|
|
properties: {
|
|
native_id: `edge-line:${road.id}:${side}${part ? `:${part}` : ''}`,
|
|
road_id: road.id,
|
|
side,
|
|
osm_way_ids: road.osmWayIds.join(','),
|
|
color: style.color,
|
|
pattern: style.pattern,
|
|
effective_style: `${style.color}-${style.pattern}`,
|
|
provenance: 'native-road-edge-line/v1',
|
|
},
|
|
geometry: { type: 'Polygon', coordinates: [ring] },
|
|
};
|
|
}
|
|
|
|
function compileCenterLines(model, overrides, junctionPlans, controls, diagnostics, options = {}) {
|
|
const features = [];
|
|
const controlFeatures = [...controls.crosswalks, ...controls.stopLines];
|
|
const segments = new Map();
|
|
for (const road of model.roads) {
|
|
if (!segments.has(road.segmentId)) segments.set(road.segmentId, []);
|
|
segments.get(road.segmentId).push(road);
|
|
}
|
|
for (const [segmentId, roads] of segments) {
|
|
const forward = roads.find((road) => road.direction === 'forward');
|
|
const backward = roads.find((road) => road.direction === 'backward');
|
|
if (roads.length !== 2 || !forward || !backward || forward.highway === 'service' || backward.highway === 'service')
|
|
continue;
|
|
const cluster = clusterForRoad(forward, options) || clusterForRoad(backward, options);
|
|
const internalCluster = cluster && roadInternalToCluster(forward, cluster);
|
|
const line = cluster
|
|
? trimLineAtComplexCluster(
|
|
forward.centerline,
|
|
forward.sourceNodeIds,
|
|
junctionPlans,
|
|
cluster,
|
|
clusterCenter(cluster, junctionPlans),
|
|
)
|
|
: trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junctionPlans);
|
|
const length = lineLengthMeters(line);
|
|
if (line.length < 2 || !Number.isFinite(length)) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'warning',
|
|
segmentId,
|
|
forward.osmWayIds,
|
|
'invalid-center-line',
|
|
'双向道路无法生成有效道路中心虚线。',
|
|
forward.centerline[0],
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
const style = centerLineStyle(overrides, segmentId);
|
|
const visibleLine = trimLineBeforeFirstControl(line, controlFeatures, CENTER_LINE_WIDTH_METERS);
|
|
const visibleLength = visibleLine ? lineLengthMeters(visibleLine) : 0;
|
|
if (!visibleLine || visibleLength < CENTER_LINE_DASH_LENGTH_METERS) continue;
|
|
const gap = style.pattern === 'solid' ? 0 : CENTER_LINE_DASH_GAP_METERS;
|
|
const markLength =
|
|
CENTER_LINE_DASH_LENGTH_METERS + (style.pattern === 'solid' ? CENTER_LINE_SOLID_OVERLAP_METERS : 0);
|
|
for (
|
|
let start = 0, dashIndex = 1;
|
|
start + markLength <= visibleLength;
|
|
start += CENTER_LINE_DASH_LENGTH_METERS + gap, dashIndex += 1
|
|
) {
|
|
const placement = pointAndAxisAlongLine(visibleLine, start + markLength / 2);
|
|
if (!placement) continue;
|
|
const clearanceRing = rectangleAt(
|
|
placement.point,
|
|
placement.axis,
|
|
[-placement.axis[1], placement.axis[0]],
|
|
markLength + CENTER_LINE_CONTROL_CLEARANCE_METERS * 2,
|
|
CENTER_LINE_WIDTH_METERS + CENTER_LINE_CONTROL_CLEARANCE_METERS * 2,
|
|
0,
|
|
);
|
|
for (const offset of style.double ? [-0.16, 0.16] : [0]) {
|
|
const ring = rectangleAt(
|
|
placement.point,
|
|
placement.axis,
|
|
[-placement.axis[1], placement.axis[0]],
|
|
markLength,
|
|
CENTER_LINE_WIDTH_METERS,
|
|
offset,
|
|
);
|
|
features.push({
|
|
type: 'Feature',
|
|
properties: {
|
|
native_id: `center-line:${segmentId}:${dashIndex}:${offset}`,
|
|
segment_id: segmentId,
|
|
road_id: forward.id,
|
|
cluster_id: cluster?.id || null,
|
|
cluster_internal: Boolean(internalCluster),
|
|
cluster_preview_hidden: Boolean(internalCluster),
|
|
directional_road_ids: roads.map((road) => road.id).join(','),
|
|
osm_way_ids: forward.osmWayIds.join(','),
|
|
dash_index: dashIndex,
|
|
dash_length_m: markLength,
|
|
dash_gap_m: gap,
|
|
color: style.color,
|
|
pattern: style.pattern,
|
|
double: Boolean(style.double),
|
|
effective_style: `${style.double ? 'double-' : ''}${style.color}-${style.pattern}`,
|
|
placement_rule: 'native-bidirectional-centerline/v1',
|
|
provenance: 'native-road-center-line/v1',
|
|
},
|
|
geometry: { type: 'Polygon', coordinates: [ring] },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return features;
|
|
}
|
|
|
|
function centerLineStyle(overrides, segmentId) {
|
|
const override = overrides.overrides.find(
|
|
(item) => item.kind === 'center-line-style' && item.segmentId === segmentId,
|
|
);
|
|
return override
|
|
? { color: override.color, pattern: override.pattern, double: Boolean(override.double) }
|
|
: { color: 'yellow', pattern: 'dashed', double: false };
|
|
}
|
|
|
|
function edgeLineStyle(overrides, roadId, side) {
|
|
const value = overrides.overrides.find(
|
|
(item) => item.kind === 'edge-line-style' && item.roadId === roadId && item.side === side,
|
|
);
|
|
return value ? { color: value.color, pattern: value.pattern } : { color: 'white', pattern: 'solid' };
|
|
}
|
|
|
|
function compileControlMarkings(model, lanes, diagnostics, junctionPlans = new Map()) {
|
|
const crosswalks = [];
|
|
const stopLines = [];
|
|
const arrivalEndpointIds = new Set(
|
|
model.connections.filter((connection) => connection.enabled).map((connection) => connection.fromEndpointId),
|
|
);
|
|
for (const crossing of model.crossings || []) {
|
|
const candidates = model.roads
|
|
.filter((road) => crossing.osmWayIds.includes(road.osmWayIds[0]))
|
|
.flatMap((road) =>
|
|
(lanes.byRoadId.get(road.id) || [])
|
|
.map((lane) => ({
|
|
road,
|
|
lane,
|
|
placement: nearestLanePlacement(lane.coordinates, crossing.coordinate),
|
|
junctionDistanceMeters: distanceMeters(crossing.coordinate, road.centerline.at(-1)),
|
|
}))
|
|
.filter((item) => item.placement),
|
|
);
|
|
const candidate = candidates.sort((a, b) => a.placement.distance - b.placement.distance)[0];
|
|
if (!candidate || candidate.placement.distance > 12) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'warning',
|
|
`crossing:node/${crossing.id}`,
|
|
[crossing.id],
|
|
'crossing-no-native-lane',
|
|
'人行横道无法匹配到安全的原生车道,未生成标线。',
|
|
crossing.coordinate,
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
const approach = candidates
|
|
.filter(
|
|
(item) =>
|
|
arrivalEndpointIds.has(`endpoint:${item.road.id}:end`) &&
|
|
item.junctionDistanceMeters > STOP_LINE_OFFSET_METERS &&
|
|
item.junctionDistanceMeters <= STOP_LINE_MAX_APPROACH_DISTANCE_METERS,
|
|
)
|
|
.sort(
|
|
(a, b) => a.junctionDistanceMeters - b.junctionDistanceMeters || a.placement.distance - b.placement.distance,
|
|
)[0];
|
|
const crosswalkCandidate = approach || candidate;
|
|
const junctionInsetMeters = approach ? crossingJunctionInset(approach, junctionPlans) : 0;
|
|
const controlCenter = offsetByMeters(crossing.coordinate, crosswalkCandidate.placement.axis, junctionInsetMeters);
|
|
const { axis } = crosswalkCandidate.placement;
|
|
const across = [-axis[1], axis[0]];
|
|
for (let index = 0; index < 6; index += 1)
|
|
crosswalks.push(
|
|
controlFeature(
|
|
'crosswalk',
|
|
crossing,
|
|
crosswalkCandidate,
|
|
index + 1,
|
|
rectangleAt(controlCenter, axis, across, 3, 0.45, -2.25 + index * 0.9),
|
|
{ junctionInsetMeters },
|
|
),
|
|
);
|
|
if (!approach) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'info',
|
|
`crossing:node/${crossing.id}`,
|
|
[crossing.id],
|
|
'crossing-no-safe-stop-line',
|
|
'人行横道没有可确认的路口进口车道,保留斑马线但未生成停止线。',
|
|
crossing.coordinate,
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
const rawRoadPlacement = nearestLanePlacement(approach.road.centerline, controlCenter);
|
|
const laneOffset = rawRoadPlacement ? project(approach.placement.point, rawRoadPlacement.point) : [0, 0];
|
|
const lateralOffset = laneOffset[0] * across[0] + laneOffset[1] * across[1];
|
|
const laneCenterAtCrossing = offsetByMeters(controlCenter, across, lateralOffset);
|
|
const stopCenter = offsetByMeters(laneCenterAtCrossing, approach.placement.axis, -STOP_LINE_OFFSET_METERS);
|
|
stopLines.push(
|
|
controlFeature(
|
|
'stop-line',
|
|
crossing,
|
|
approach,
|
|
1,
|
|
rectangleAt(stopCenter, across, approach.placement.axis, approach.road.widthMeters, 0.45, 0),
|
|
{ junctionInsetMeters },
|
|
),
|
|
);
|
|
}
|
|
return { crosswalks, stopLines };
|
|
}
|
|
|
|
function crossingJunctionInset(candidate, junctionPlans) {
|
|
const junctionNodeId = candidate.road.sourceNodeIds.at(-1);
|
|
const plan = junctionPlans.get(junctionNodeId);
|
|
if (!plan) return 0;
|
|
const targetDistance = Math.max(0, plan.cutbackMeters - CROSSWALK_JUNCTION_INSET_METERS);
|
|
return Math.min(CROSSWALK_MAX_JUNCTION_INSET_METERS, Math.max(0, candidate.junctionDistanceMeters - targetDistance));
|
|
}
|
|
|
|
function nearestLanePlacement(line, target) {
|
|
let best = null;
|
|
let traversedMeters = 0;
|
|
for (let index = 1; index < line.length; index += 1) {
|
|
const a = line[index - 1];
|
|
const b = line[index];
|
|
const vector = project(b, a);
|
|
const length = Math.hypot(...vector);
|
|
if (!length) continue;
|
|
const relative = project(target, a);
|
|
const ratio = Math.max(0, Math.min(1, (relative[0] * vector[0] + relative[1] * vector[1]) / (length * length)));
|
|
const point = interpolate(a, b, ratio);
|
|
const distance = distanceMeters(point, target);
|
|
if (!best || distance < best.distance)
|
|
best = {
|
|
point,
|
|
axis: [vector[0] / length, vector[1] / length],
|
|
distance,
|
|
distanceToEndMeters: lineLengthMeters(line) - traversedMeters - length * ratio,
|
|
};
|
|
traversedMeters += length;
|
|
}
|
|
return best;
|
|
}
|
|
function offsetByMeters(point, axis, meters) {
|
|
return unproject([axis[0] * meters, axis[1] * meters], point);
|
|
}
|
|
function rectangleAt(center, axis, across, length, width, offset) {
|
|
const shifted = offsetByMeters(center, across, offset);
|
|
const corners = [
|
|
[-length / 2, -width / 2],
|
|
[length / 2, -width / 2],
|
|
[length / 2, width / 2],
|
|
[-length / 2, width / 2],
|
|
].map(([forward, side]) =>
|
|
unproject([axis[0] * forward + across[0] * side, axis[1] * forward + across[1] * side], shifted),
|
|
);
|
|
return [...corners, corners[0]];
|
|
}
|
|
function controlFeature(kind, crossing, candidate, part, ring, placement = {}) {
|
|
const stop = kind === 'stop-line';
|
|
return {
|
|
type: 'Feature',
|
|
properties: {
|
|
native_id: `${kind}:node/${crossing.id}:${part}`,
|
|
crossing_node_id: crossing.id,
|
|
road_id: candidate.road.id,
|
|
lane_id: candidate.lane.id,
|
|
osm_way_ids: candidate.road.osmWayIds.join(','),
|
|
direction: candidate.road.direction,
|
|
placement_method: 'native-lane-nearest-point/v1',
|
|
junction_inset_m: Math.round((placement.junctionInsetMeters || 0) * 100) / 100,
|
|
provenance: stop ? 'native-road-stop-line/v1' : 'native-road-crosswalk/v1',
|
|
},
|
|
geometry: { type: 'Polygon', coordinates: [ring] },
|
|
};
|
|
}
|
|
|
|
function compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans, controls, options = {}) {
|
|
const separators = [];
|
|
const directionArrows = [];
|
|
const turnArrows = [];
|
|
const controlFeatures = [...controls.crosswalks, ...controls.stopLines];
|
|
for (const road of model.roads) {
|
|
const cluster = clusterForRoad(road, options);
|
|
const internalCluster = cluster && roadInternalToCluster(road, cluster);
|
|
const roadLanes = (lanes.markingByRoadId || lanes.byRoadId).get(road.id) || [];
|
|
for (let index = 1; index < roadLanes.length; index += 1) {
|
|
const left = roadLanes[index - 1].coordinates;
|
|
const right = roadLanes[index].coordinates;
|
|
if (left.length !== right.length) continue;
|
|
const centerline = left.map((point, pointIndex) => [
|
|
(point[0] + right[pointIndex][0]) / 2,
|
|
(point[1] + right[pointIndex][1]) / 2,
|
|
]);
|
|
const style = laneSeparatorStyle(overrides, road.id, index, index + 1);
|
|
const properties = {
|
|
road_id: road.id,
|
|
left_lane_index: index,
|
|
right_lane_index: index + 1,
|
|
cluster_id: cluster?.id || null,
|
|
cluster_internal: Boolean(internalCluster),
|
|
cluster_preview_hidden: Boolean(internalCluster),
|
|
osm_way_ids: road.osmWayIds.join(','),
|
|
color: style.color,
|
|
pattern: style.pattern,
|
|
effective_style: `${style.color}-${style.pattern}`,
|
|
provenance: 'native-road-lane-separator/v1',
|
|
};
|
|
if (style.pattern === 'solid') {
|
|
const visibleLine = trimLineBeforeFirstControl(centerline, controlFeatures, 0.12);
|
|
const ring = visibleLine ? roadRing(visibleLine, 0.12) : null;
|
|
if (ring)
|
|
separators.push({
|
|
type: 'Feature',
|
|
properties: { native_id: `lane-separator:${road.id}:${index}-${index + 1}`, ...properties },
|
|
geometry: { type: 'Polygon', coordinates: [ring] },
|
|
});
|
|
} else {
|
|
const visibleLine = trimLineBeforeFirstControl(centerline, controlFeatures, 0.12);
|
|
const visibleLength = visibleLine ? lineLengthMeters(visibleLine) : 0;
|
|
for (let distance = 1, part = 1; visibleLine && distance + 1 <= visibleLength; distance += 4, part += 1) {
|
|
const placement = pointAndAxisAlongLine(visibleLine, distance);
|
|
if (!placement) continue;
|
|
const ring = rectangleAt(
|
|
placement.point,
|
|
placement.axis,
|
|
[-placement.axis[1], placement.axis[0]],
|
|
2,
|
|
0.12,
|
|
0,
|
|
);
|
|
separators.push({
|
|
type: 'Feature',
|
|
properties: { native_id: `lane-separator:${road.id}:${index}-${index + 1}:${part}`, ...properties },
|
|
geometry: { type: 'Polygon', coordinates: [ring] },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
for (const lane of roadLanes)
|
|
directionArrows.push(
|
|
...directionArrowFeatures(road, lane, controlFeatures, diagnostics).map((feature) => ({
|
|
...feature,
|
|
properties: {
|
|
...feature.properties,
|
|
cluster_id: cluster?.id || null,
|
|
cluster_internal: Boolean(internalCluster),
|
|
cluster_preview_hidden: Boolean(internalCluster),
|
|
},
|
|
})),
|
|
);
|
|
const turns = road.tags[`turn:lanes:${road.direction}`] ?? road.tags['turn:lanes'];
|
|
const maneuvers = turns ? String(turns).split('|') : [];
|
|
for (let index = 0; index < roadLanes.length; index += 1) {
|
|
const lane = roadLanes[index];
|
|
const explicitManeuver = maneuvers[index];
|
|
if (!explicitManeuver) continue;
|
|
const maneuver = normalizeManeuver(explicitManeuver);
|
|
if (!lane) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'warning',
|
|
road.id,
|
|
road.osmWayIds,
|
|
'turn-arrow-lane-missing',
|
|
'转向标签引用了不存在的车道,未生成箭头。',
|
|
road.centerline.at(-1),
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
if (!arrowRingsAt(maneuver, lane.coordinates.at(-1), [0, 1]).length) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'info',
|
|
lane.id,
|
|
road.osmWayIds,
|
|
'turn-arrow-unsupported',
|
|
'转向标签不在当前已测试的箭头集合中,未生成箭头。',
|
|
lane.coordinates.at(-1),
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
if (lineLengthMeters(lane.coordinates) < 8) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'warning',
|
|
lane.id,
|
|
road.osmWayIds,
|
|
'turn-arrow-no-safe-placement',
|
|
'驶入路口前的车道过短,未生成转向箭头。',
|
|
lane.coordinates.at(-1),
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
const previous = lane.coordinates.at(-2);
|
|
const end = lane.coordinates.at(-1);
|
|
const meters = project(end, end);
|
|
const vector = project(previous, end);
|
|
const length = Math.hypot(-vector[0], -vector[1]);
|
|
const axis = length ? [-vector[0] / length, -vector[1] / length] : null;
|
|
const placement = axis
|
|
? [6, 10, 14, 18, 22].find(
|
|
(distance) =>
|
|
distance < lineLengthMeters(lane.coordinates) - 2 &&
|
|
!ringsOverlapControl(
|
|
arrowRingsAt(maneuver, pointAlongLine([...lane.coordinates].reverse(), distance), axis),
|
|
controlFeatures,
|
|
),
|
|
)
|
|
: null;
|
|
if (!placement) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'info',
|
|
lane.id,
|
|
road.osmWayIds,
|
|
'turn-arrow-control-conflict',
|
|
'转向箭头会压住斑马线或停止线,未生成该箭头。',
|
|
lane.coordinates.at(-1),
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
const center = pointAlongLine([...lane.coordinates].reverse(), placement);
|
|
const rings = arrowRingsAt(maneuver, center, axis);
|
|
if (!rings.length) continue;
|
|
for (let part = 0; part < rings.length; part += 1)
|
|
turnArrows.push({
|
|
type: 'Feature',
|
|
properties: {
|
|
native_id: `turn-arrow:${lane.id}:${maneuver}:${part}`,
|
|
road_id: road.id,
|
|
lane_id: lane.id,
|
|
cluster_id: cluster?.id || null,
|
|
cluster_internal: Boolean(internalCluster),
|
|
cluster_preview_hidden: Boolean(internalCluster),
|
|
osm_way_ids: road.osmWayIds.join(','),
|
|
direction: road.direction,
|
|
lane_index: lane.index,
|
|
maneuver,
|
|
arrow_part: part,
|
|
placement_distance_meters: placement,
|
|
provenance: 'native-road-turn-arrow/v1',
|
|
},
|
|
geometry: { type: 'Polygon', coordinates: [rings[part]] },
|
|
});
|
|
}
|
|
}
|
|
return { separators, directionArrows, turnArrows };
|
|
}
|
|
|
|
function clusterForRoad(road, options) {
|
|
const clusters = options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : [];
|
|
return (
|
|
clusters.find(
|
|
(cluster) =>
|
|
cluster.template === 'complex-junction-v1' &&
|
|
road.sourceNodeIds.some((nodeId) => cluster.nodeIds.map(String).includes(String(nodeId))),
|
|
) || null
|
|
);
|
|
}
|
|
|
|
function roadInternalToCluster(road, cluster) {
|
|
const nodeIds = new Set(cluster.nodeIds.map(String));
|
|
return nodeIds.has(String(road.sourceNodeIds[0])) && nodeIds.has(String(road.sourceNodeIds.at(-1)));
|
|
}
|
|
|
|
function clusterCenter(cluster, junctionPlans) {
|
|
const points = cluster.nodeIds.map((nodeId) => junctionPlans.get(String(nodeId))?.node).filter(Boolean);
|
|
return points.length
|
|
? points.reduce((sum, point) => [sum[0] + point[0] / points.length, sum[1] + point[1] / points.length], [0, 0])
|
|
: null;
|
|
}
|
|
|
|
function angularDistance(first, second) {
|
|
return Math.abs(((first - second + 180) % 360) - 180);
|
|
}
|
|
|
|
function laneSeparatorStyle(overrides, roadId, leftLaneIndex, rightLaneIndex) {
|
|
const value = overrides.overrides.find(
|
|
(item) =>
|
|
item.kind === 'lane-separator-style' &&
|
|
item.roadId === roadId &&
|
|
item.leftLaneIndex === leftLaneIndex &&
|
|
item.rightLaneIndex === rightLaneIndex,
|
|
);
|
|
return value ? { color: value.color, pattern: value.pattern } : { color: 'white', pattern: 'dashed' };
|
|
}
|
|
|
|
function directionArrowFeatures(road, lane, controlFeatures, diagnostics) {
|
|
const length = lineLengthMeters(lane.coordinates);
|
|
const features = [];
|
|
for (
|
|
let distance = DIRECTION_ARROW_ENDPOINT_BUFFER_METERS, sequence = 1;
|
|
distance <= length - DIRECTION_ARROW_ENDPOINT_BUFFER_METERS;
|
|
distance += DIRECTION_ARROW_INTERVAL_METERS, sequence += 1
|
|
) {
|
|
const placement = pointAndAxisAlongLine(lane.coordinates, distance);
|
|
if (!placement) continue;
|
|
const rings = arrowRingsAt('through', placement.point, placement.axis);
|
|
if (ringsOverlapControl(rings, controlFeatures)) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'info',
|
|
lane.id,
|
|
road.osmWayIds,
|
|
'direction-arrow-control-conflict',
|
|
'默认直行箭头会压住斑马线或停止线,已跳过该位置。',
|
|
placement.point,
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
for (let part = 0; part < rings.length; part += 1)
|
|
features.push({
|
|
type: 'Feature',
|
|
properties: {
|
|
native_id: `direction-arrow:${lane.id}:${sequence}:${part}`,
|
|
road_id: road.id,
|
|
lane_id: lane.id,
|
|
osm_way_ids: road.osmWayIds.join(','),
|
|
direction: road.direction,
|
|
lane_index: lane.index,
|
|
maneuver: 'through',
|
|
sequence,
|
|
distance_along_lane_meters: Math.round(distance * 10) / 10,
|
|
placement_interval_meters: DIRECTION_ARROW_INTERVAL_METERS,
|
|
provenance: 'native-road-direction-arrow/v1',
|
|
},
|
|
geometry: { type: 'Polygon', coordinates: [rings[part]] },
|
|
});
|
|
}
|
|
return features;
|
|
}
|
|
|
|
function ringsOverlapControl(rings, controls) {
|
|
return rings.some((ring) => controls.some((feature) => ringsOverlap(ring, feature.geometry.coordinates[0])));
|
|
}
|
|
function ringsOverlap(first, second) {
|
|
const bounds = (ring) => [
|
|
Math.min(...ring.map((point) => point[0])),
|
|
Math.min(...ring.map((point) => point[1])),
|
|
Math.max(...ring.map((point) => point[0])),
|
|
Math.max(...ring.map((point) => point[1])),
|
|
];
|
|
const a = bounds(first);
|
|
const b = bounds(second);
|
|
if (a[0] > b[2] || a[2] < b[0] || a[1] > b[3] || a[3] < b[1]) return false;
|
|
if (first.some((point) => pointInPolygon(point, second)) || second.some((point) => pointInPolygon(point, first)))
|
|
return true;
|
|
return first
|
|
.slice(1)
|
|
.some((point, index) =>
|
|
second.slice(1).some((other, otherIndex) => segmentsIntersect(first[index], point, second[otherIndex], other)),
|
|
);
|
|
}
|
|
|
|
function compileSidewalkSurfaces(model, diagnostics, junctionPlans, options = {}) {
|
|
const features = [];
|
|
const byWay = new Map();
|
|
for (const road of model.roads) {
|
|
const key = road.segmentId;
|
|
if (!byWay.has(key)) byWay.set(key, []);
|
|
byWay.get(key).push(road);
|
|
}
|
|
for (const [wayKey, directions] of byWay) {
|
|
const forward = directions.find((road) => road.direction === 'forward') || directions[0];
|
|
const backward = directions.find((road) => road.id !== forward.id);
|
|
const totalWidth = directions.reduce((sum, road) => sum + road.widthMeters, 0);
|
|
const sides = [
|
|
['left', forward.sidewalkLeft || Boolean(backward?.sidewalkRight)],
|
|
['right', forward.sidewalkRight || Boolean(backward?.sidewalkLeft)],
|
|
];
|
|
const cluster = clusterForRoad(forward, options) || (backward ? clusterForRoad(backward, options) : null);
|
|
const center = cluster ? clusterCenter(cluster, junctionPlans) : null;
|
|
for (const [side, enabled] of sides) {
|
|
if (!enabled) continue;
|
|
const centerline = cluster
|
|
? trimLineAtComplexCluster(forward.centerline, forward.sourceNodeIds, junctionPlans, cluster, center)
|
|
: trimLineAtJunctions(forward.centerline, forward.sourceNodeIds, junctionPlans);
|
|
const ring = sidewalkRing(
|
|
centerline,
|
|
totalWidth / 2,
|
|
totalWidth / 2 + DEFAULT_SIDEWALK_WIDTH_METERS,
|
|
side === 'left' ? 1 : -1,
|
|
);
|
|
if (!ring) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'warning',
|
|
forward.id,
|
|
forward.osmWayIds,
|
|
'invalid-sidewalk-surface',
|
|
'无法为该道路生成连续人行道面。',
|
|
forward.centerline[0],
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
const sidewalkId = forward.segmentId.endsWith('/0')
|
|
? `sidewalk:way/${forward.osmWayIds.join(',')}:${side}`
|
|
: `sidewalk:${wayKey}:${side}`;
|
|
features.push({
|
|
type: 'Feature',
|
|
properties: {
|
|
native_id: sidewalkId,
|
|
cluster_id: cluster?.id || null,
|
|
osm_way_ids: forward.osmWayIds.join(','),
|
|
source_road_id: forward.sourceRoadId,
|
|
side,
|
|
width_m: DEFAULT_SIDEWALK_WIDTH_METERS,
|
|
directional_road_ids: directions.map((road) => road.id).join(','),
|
|
provenance: 'native-road-sidewalk/v1',
|
|
override_ids: directions.flatMap((road) => road.appliedOverrideIds).join(','),
|
|
},
|
|
geometry: { type: 'Polygon', coordinates: [ring] },
|
|
});
|
|
}
|
|
}
|
|
features.push(...compileSidewalkCorners(model, junctionPlans, options));
|
|
return features;
|
|
}
|
|
|
|
function compileSidewalkCorners(model, junctionPlans, options = {}) {
|
|
const result = [];
|
|
for (const [nodeId, plan] of junctionPlans) {
|
|
if (plan.clusterId && activeComplexCluster(options, plan.clusterId)) continue;
|
|
const candidates = [];
|
|
for (const approach of plan.approaches) {
|
|
const directions = model.roads.filter((road) => road.segmentId === approach.segmentId);
|
|
const forward = directions.find((road) => road.direction === 'forward') || directions[0];
|
|
if (!forward) continue;
|
|
const outwardIsForward = forward.sourceNodeIds[0] === nodeId;
|
|
const sideStates = outwardIsForward
|
|
? { left: forward.sidewalkLeft, right: forward.sidewalkRight }
|
|
: { left: forward.sidewalkRight, right: forward.sidewalkLeft };
|
|
const cutback = pointAlongLine(approach.line, plan.cutbackMeters);
|
|
if (!cutback) continue;
|
|
const heading = headingAtEndpoint(approach.line);
|
|
const halfWidth = approach.widthMeters / 2;
|
|
for (const [side, enabled] of Object.entries(sideStates)) {
|
|
if (!enabled) continue;
|
|
// offsetLine's positive normal is driver's left, which is heading -90
|
|
// in this north-based heading convention.
|
|
const sideHeading = heading + (side === 'left' ? -90 : 90);
|
|
candidates.push({
|
|
wayKey: approach.segmentId,
|
|
sourceWayKey: forward.osmWayIds.join(','),
|
|
side,
|
|
outwardHeading: heading,
|
|
normalDegrees: sideHeading,
|
|
curb: offsetCoordinate(cutback, sideHeading, halfWidth),
|
|
outer: offsetCoordinate(cutback, sideHeading, halfWidth + DEFAULT_SIDEWALK_WIDTH_METERS),
|
|
});
|
|
}
|
|
}
|
|
candidates.sort((a, b) => angleAround(plan.node, a.curb) - angleAround(plan.node, b.curb));
|
|
for (let index = 0; index < candidates.length; index += 1) {
|
|
const first = candidates[index];
|
|
const second = candidates[(index + 1) % candidates.length];
|
|
if (first.wayKey === second.wayKey) continue;
|
|
const continuation = isStraightSidewalkContinuation(first, second);
|
|
if (first.sourceWayKey === second.sourceWayKey && !continuation) continue;
|
|
// A split-through road has two approaches at this node. Its pedestrian
|
|
// strip is a direct continuation, not a curb corner. Treating it as a
|
|
// curve creates the oversized outer lobe seen at T junctions.
|
|
const ring = continuation
|
|
? [first.curb, first.outer, second.outer, second.curb, first.curb]
|
|
: roundedSidewalkCorner(plan.node, first, second);
|
|
if (hasSelfIntersection(ring)) continue;
|
|
if (continuation && cornerFallsIntoOtherApproach(ring, first.sourceWayKey, plan.approaches)) continue;
|
|
result.push({
|
|
type: 'Feature',
|
|
properties: {
|
|
native_id: `sidewalk-corner:node/${nodeId}:${first.wayKey}:${first.side}->${second.wayKey}:${second.side}`,
|
|
osm_node_id: nodeId,
|
|
kind: continuation ? 'continuation' : 'corner',
|
|
width_m: DEFAULT_SIDEWALK_WIDTH_METERS,
|
|
provenance: continuation ? 'native-road-sidewalk-continuation/v1' : 'native-road-sidewalk-corner/v1',
|
|
},
|
|
geometry: { type: 'Polygon', coordinates: [ring] },
|
|
});
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function roundedSidewalkCorner(node, first, second) {
|
|
// Keep the established vehicle curb geometry, then derive the outer edge
|
|
// from it. Independent Bezier curves drift apart and leave asphalt exposed
|
|
// between the junction and pedestrian layers.
|
|
const curbForward = roundedCorner(node, first.curb, second.curb, first.outwardHeading, second.outwardHeading) || [
|
|
first.curb,
|
|
second.curb,
|
|
];
|
|
// Construct the outside edge from the same tangent-support rule. A linear
|
|
// point-by-point offset changes the curvature and makes the two boundaries
|
|
// visibly disagree at the middle of the corner.
|
|
const outerForward =
|
|
roundedCorner(node, first.outer, second.outer, first.outwardHeading, second.outwardHeading) ||
|
|
offsetCornerArc(curbForward, first.curb, first.outer, second.curb, second.outer);
|
|
const curbArc = [...curbForward].reverse();
|
|
return [
|
|
first.curb,
|
|
first.outer,
|
|
...outerForward.slice(1, -1),
|
|
second.outer,
|
|
second.curb,
|
|
...curbArc.slice(1, -1),
|
|
first.curb,
|
|
];
|
|
}
|
|
|
|
function offsetCornerArc(curbArc, firstCurb, firstOuter, secondCurb, secondOuter) {
|
|
return curbArc.map((point, index) => {
|
|
const ratio = curbArc.length === 1 ? 0 : index / (curbArc.length - 1);
|
|
const firstOffset = [firstOuter[0] - firstCurb[0], firstOuter[1] - firstCurb[1]];
|
|
const secondOffset = [secondOuter[0] - secondCurb[0], secondOuter[1] - secondCurb[1]];
|
|
return [
|
|
point[0] + firstOffset[0] + (secondOffset[0] - firstOffset[0]) * ratio,
|
|
point[1] + firstOffset[1] + (secondOffset[1] - firstOffset[1]) * ratio,
|
|
];
|
|
});
|
|
}
|
|
|
|
function samePhysicalSide(first, second) {
|
|
const radians = ((first.normalDegrees - second.normalDegrees) * Math.PI) / 180;
|
|
return Math.cos(radians) >= 0.98;
|
|
}
|
|
|
|
function isStraightSidewalkContinuation(first, second) {
|
|
if (first.sourceWayKey !== second.sourceWayKey || !samePhysicalSide(first, second)) return false;
|
|
const radians = ((first.outwardHeading - second.outwardHeading) * Math.PI) / 180;
|
|
return Math.cos(radians) <= -0.98;
|
|
}
|
|
|
|
function cornerFallsIntoOtherApproach(ring, sourceWayKey, approaches) {
|
|
const vertices = ring.slice(0, -1);
|
|
const center = vertices.reduce(
|
|
(sum, point) => [sum[0] + point[0] / vertices.length, sum[1] + point[1] / vertices.length],
|
|
[0, 0],
|
|
);
|
|
return approaches
|
|
.filter((approach) => approach.sourceWayKey !== sourceWayKey)
|
|
.some((approach) => {
|
|
const carriageway = roadRing(approach.line, approach.widthMeters);
|
|
return carriageway && pointInPolygon(center, carriageway);
|
|
});
|
|
}
|
|
|
|
function validateConnectorContainment(connectors, junctionFeatures, diagnostics) {
|
|
const junctionByNode = new Map();
|
|
for (const feature of junctionFeatures) {
|
|
if (feature.properties.osm_node_ids)
|
|
for (const nodeId of String(feature.properties.osm_node_ids).split(',')) junctionByNode.set(nodeId, feature);
|
|
else if (feature.properties.osm_node_id) junctionByNode.set(feature.properties.osm_node_id, feature);
|
|
}
|
|
for (const connector of connectors) {
|
|
const junction = junctionByNode.get(connector.properties.node_id);
|
|
if (!junction) continue;
|
|
if (junction.properties.kind === 'cluster') continue;
|
|
const ring = junction.geometry.coordinates[0];
|
|
if (
|
|
!connector.geometry.coordinates.every((point) =>
|
|
pointInOrNearPolygon(point, ring, CONNECTOR_BOUNDARY_TOLERANCE_METERS),
|
|
)
|
|
) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'warning',
|
|
connector.properties.connection_id,
|
|
[connector.properties.node_id],
|
|
'connector-outside-junction',
|
|
'转向路径有部分落在路口面外,请检查道路截面或转向连接。',
|
|
connector.geometry.coordinates[0],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function pointInPolygon(point, ring) {
|
|
for (let index = 1; index < ring.length; index += 1)
|
|
if (pointOnSegment(point, ring[index - 1], ring[index])) return true;
|
|
let inside = false;
|
|
for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) {
|
|
const a = ring[index];
|
|
const b = ring[previous];
|
|
const intersect =
|
|
a[1] > point[1] !== b[1] > point[1] && point[0] < ((b[0] - a[0]) * (point[1] - a[1])) / (b[1] - a[1]) + a[0];
|
|
if (intersect) inside = !inside;
|
|
}
|
|
return inside;
|
|
}
|
|
function pointInOrNearPolygon(point, ring, toleranceMeters) {
|
|
return (
|
|
pointInPolygon(point, ring) ||
|
|
ring.slice(1).some((end, index) => distancePointToSegmentMeters(point, ring[index], end) <= toleranceMeters)
|
|
);
|
|
}
|
|
function distancePointToSegmentMeters(point, start, end) {
|
|
const localPoint = project(point, start);
|
|
const localEnd = project(end, start);
|
|
const lengthSquared = localEnd[0] ** 2 + localEnd[1] ** 2;
|
|
if (lengthSquared < 0.0001) return Math.hypot(...localPoint);
|
|
const ratio = Math.max(0, Math.min(1, (localPoint[0] * localEnd[0] + localPoint[1] * localEnd[1]) / lengthSquared));
|
|
return Math.hypot(localPoint[0] - localEnd[0] * ratio, localPoint[1] - localEnd[1] * ratio);
|
|
}
|
|
function pointOnSegment(point, a, b) {
|
|
const cross = (point[0] - a[0]) * (b[1] - a[1]) - (point[1] - a[1]) * (b[0] - a[0]);
|
|
if (Math.abs(cross) > 1e-12) return false;
|
|
return (
|
|
point[0] >= Math.min(a[0], b[0]) - 1e-12 &&
|
|
point[0] <= Math.max(a[0], b[0]) + 1e-12 &&
|
|
point[1] >= Math.min(a[1], b[1]) - 1e-12 &&
|
|
point[1] <= Math.max(a[1], b[1]) + 1e-12
|
|
);
|
|
}
|
|
|
|
// `complexControls` carries the crosswalks and stop bars the complex-junction
|
|
// templates already emitted. Ordinary controls cannot be passed here: they are
|
|
// placed *from* these lane centerlines, so only the template-generated ones
|
|
// exist this early.
|
|
function compileLaneCenterlines(model, diagnostics, junctionPlans, options = {}, complexControls = {}) {
|
|
const features = [];
|
|
const controlFeatures = [...(complexControls.crosswalks || []), ...(complexControls.stopLines || [])];
|
|
const byRoadId = new Map();
|
|
const markingByRoadId = new Map();
|
|
const clusters = options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : [];
|
|
const clusterByNode = new Map(
|
|
clusters.flatMap((cluster) => cluster.nodeIds.map((nodeId) => [String(nodeId), cluster])),
|
|
);
|
|
const clusterCenters = new Map(clusters.map((cluster) => [cluster.id, clusterCenter(cluster, junctionPlans)]));
|
|
for (const road of model.roads) {
|
|
const lanes = [];
|
|
const markingLanes = [];
|
|
const laneWidth = road.widthMeters / road.laneCount;
|
|
const siblings = model.roads.filter((item) => item.segmentId === road.segmentId);
|
|
const opposite = siblings.find((item) => item.id !== road.id);
|
|
const boundaryCluster =
|
|
clusterByNode.get(String(road.sourceNodeIds[0])) || clusterByNode.get(String(road.sourceNodeIds.at(-1)));
|
|
const internalCluster = boundaryCluster && roadInternalToCluster(road, boundaryCluster);
|
|
const clippedRoadLine =
|
|
boundaryCluster?.template === 'complex-junction-v1'
|
|
? trimLineAtComplexCluster(
|
|
road.centerline,
|
|
road.sourceNodeIds,
|
|
junctionPlans,
|
|
boundaryCluster,
|
|
clusterCenters.get(boundaryCluster.id),
|
|
)
|
|
: trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans);
|
|
// OSM centerline is the shared carriageway center. On a two-way road,
|
|
// offset each directed carriageway to its own side before placing lanes.
|
|
const carriagewayOffset = opposite
|
|
? road.direction === 'forward'
|
|
? -opposite.widthMeters / 2
|
|
: -road.widthMeters / 2
|
|
: 0;
|
|
for (let index = 0; index < road.laneCount; index += 1) {
|
|
// OSM `turn:lanes` is ordered from left to right. Keep lane 1 on the
|
|
// driver's left so tag positions and generated lane IDs have one meaning.
|
|
const offset = carriagewayOffset + (road.widthMeters / 2 - laneWidth * (index + 0.5));
|
|
const coordinates = offsetLine(trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans), offset);
|
|
const publishedCoordinates = offsetLine(clippedRoadLine, offset);
|
|
if (!coordinates || !publishedCoordinates) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'error',
|
|
road.id,
|
|
road.osmWayIds,
|
|
'invalid-lane-centerline',
|
|
'无法为该道路生成车道中心线。',
|
|
road.centerline[0],
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
const lane = { id: `lane:${road.id}:${index + 1}`, roadId: road.id, index: index + 1, coordinates };
|
|
lanes.push(lane);
|
|
// Only the published geometry stops at the crossing. `coordinates` stays
|
|
// whole because connectors are derived from it; a lane that ends at the
|
|
// stop bar would otherwise break every turn path through the junction.
|
|
const visibleCoordinates =
|
|
boundaryCluster?.template === 'complex-junction-v1'
|
|
? trimLaneOutsideControls(
|
|
publishedCoordinates,
|
|
controlFeatures,
|
|
LANE_CENTERLINE_PROBE_WIDTH_METERS,
|
|
clusterCenters.get(boundaryCluster.id),
|
|
)
|
|
: publishedCoordinates;
|
|
if (!visibleCoordinates) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'warning',
|
|
road.id,
|
|
road.osmWayIds,
|
|
'lane-centerline-fully-inside-control',
|
|
'该车道中心线整体落在斑马线或停止线内,已按未裁剪几何发布。',
|
|
publishedCoordinates[0],
|
|
),
|
|
);
|
|
}
|
|
// Lane markings are laid out along this line. Feeding it the clipped
|
|
// geometry is what keeps separators and arrows from being painted *past*
|
|
// a crossing: control avoidance only stops them landing *on* one.
|
|
markingLanes.push({ ...lane, coordinates: visibleCoordinates || publishedCoordinates });
|
|
features.push({
|
|
type: 'Feature',
|
|
properties: {
|
|
native_id: lane.id,
|
|
road_id: road.id,
|
|
lane_index: lane.index,
|
|
cluster_id: boundaryCluster?.id || null,
|
|
cluster_internal: Boolean(internalCluster),
|
|
cluster_preview_hidden: Boolean(internalCluster),
|
|
cluster_boundary_clipped: Boolean(boundaryCluster && !internalCluster),
|
|
control_clipped: laneWasClipped(publishedCoordinates, visibleCoordinates),
|
|
source: 'native-road-lane-centerline/v3-control-clipped',
|
|
},
|
|
geometry: { type: 'LineString', coordinates: visibleCoordinates || publishedCoordinates },
|
|
});
|
|
}
|
|
byRoadId.set(road.id, lanes);
|
|
markingByRoadId.set(road.id, markingLanes);
|
|
}
|
|
for (const cluster of clusters) {
|
|
const clusterNodes = new Set(cluster.nodeIds.map(String));
|
|
const clusterCoordinates = [...clusterNodes].map((nodeId) => junctionPlans.get(nodeId)?.node).filter(Boolean);
|
|
const compositeCenter = clusterCoordinates.length
|
|
? clusterCoordinates.reduce(
|
|
(sum, point) => [
|
|
sum[0] + point[0] / clusterCoordinates.length,
|
|
sum[1] + point[1] / clusterCoordinates.length,
|
|
],
|
|
[0, 0],
|
|
)
|
|
: null;
|
|
const corridors = [];
|
|
for (const [nodeId, plan] of junctionPlans) {
|
|
if (!clusterNodes.has(String(nodeId))) continue;
|
|
for (const approach of plan.approaches) {
|
|
const end = approach.line.at(-1);
|
|
if (
|
|
[...clusterNodes].some(
|
|
(candidate) =>
|
|
candidate !== String(nodeId) &&
|
|
distanceMeters(end, junctionPlans.get(candidate)?.node || [Infinity, Infinity]) < 3,
|
|
)
|
|
)
|
|
continue;
|
|
const heading = ((headingAtEndpoint(approach.line) + 180) % 360) - 180;
|
|
corridors.push({ nodeId, heading, approach, plan });
|
|
}
|
|
}
|
|
for (const corridor of corridors) {
|
|
const approach = corridor.approach;
|
|
const plan = corridor.plan;
|
|
const outerRadius = complexJunctionMetrics(cluster).approachOuterRadius;
|
|
const length = Math.min(
|
|
distanceAlongLineToRadius(approach.line, compositeCenter, outerRadius),
|
|
lineLengthMeters(approach.line),
|
|
);
|
|
if (length < 12) continue;
|
|
const line = approach.line;
|
|
const outer = pointAlongLine(line, Math.max(0, length));
|
|
const inner = pointAlongLine(
|
|
line,
|
|
Math.min(Math.max(3, Number(cluster.coreRadiusMeters || 28) * 0.14), Math.max(3, length - 8)),
|
|
);
|
|
const corridorRoads = approach.roadIds
|
|
.map((roadId) => model.roads.find((road) => road.id === roadId))
|
|
.filter(Boolean);
|
|
const incoming = corridorRoads.some((road) => String(road.sourceNodeIds.at(-1)) === String(corridor.nodeId));
|
|
const count = Math.max(
|
|
1,
|
|
corridorRoads.reduce((sum, road) => sum + road.laneCount, 0),
|
|
);
|
|
const laneWidth = approach.widthMeters / count;
|
|
const axis = project(inner, outer);
|
|
const total = Math.hypot(...axis);
|
|
if (!total) continue;
|
|
const normalized = [axis[0] / total, axis[1] / total];
|
|
const across = [-normalized[1], normalized[0]];
|
|
for (let index = 0; index < count; index += 1) {
|
|
const offset = approach.widthMeters / 2 - laneWidth * (index + 0.5);
|
|
const start = unproject([across[0] * offset, across[1] * offset], outer);
|
|
const end = unproject([across[0] * offset, across[1] * offset], inner);
|
|
const maneuver = incoming ? (index === 0 ? 'left' : index === count - 1 ? 'right' : 'through') : 'outbound';
|
|
// Preview lanes are laid out radially from the outer radius inwards, so
|
|
// an untrimmed one runs straight over the arm crossing. Stop it at the
|
|
// first control: incoming lanes land on the stop bar, outgoing lanes on
|
|
// the far edge of the crossing.
|
|
const visible = trimLaneOutsideControls(
|
|
[start, end],
|
|
controlFeatures,
|
|
LANE_CENTERLINE_PROBE_WIDTH_METERS,
|
|
compositeCenter,
|
|
);
|
|
features.push({
|
|
type: 'Feature',
|
|
properties: {
|
|
native_id: `cluster-approach-lane:${cluster.id}:${approach.segmentId}:${index + 1}`,
|
|
road_id: corridorRoads[0]?.id || null,
|
|
cluster_id: cluster.id,
|
|
cluster_preview: true,
|
|
incoming,
|
|
lane_index: index + 1,
|
|
maneuver,
|
|
control_clipped: laneWasClipped([start, end], visible),
|
|
source: 'native-road-junction-cluster-lane/v4-control-clipped',
|
|
},
|
|
geometry: { type: 'LineString', coordinates: visible || [start, end] },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return { features, byRoadId, markingByRoadId };
|
|
}
|
|
|
|
function cubicTurnCurve(start, end, startHeading, endHeading, radius, turn, center) {
|
|
const reach = turn === 'right' ? Math.max(5, radius * 0.75) : Math.max(9, radius * 1.35);
|
|
const first = offsetCoordinate(start, startHeading, reach);
|
|
const second = offsetCoordinate(end, endHeading, reach);
|
|
const points = [];
|
|
for (let index = 0; index <= 18; index += 1) {
|
|
const t = index / 18;
|
|
const inverse = 1 - t;
|
|
points.push([
|
|
inverse ** 3 * start[0] + 3 * inverse ** 2 * t * first[0] + 3 * inverse * t ** 2 * second[0] + t ** 3 * end[0],
|
|
inverse ** 3 * start[1] + 3 * inverse ** 2 * t * first[1] + 3 * inverse * t ** 2 * second[1] + t ** 3 * end[1],
|
|
]);
|
|
}
|
|
return points.every((point) => point.every(Number.isFinite)) ? points : [start, center, end];
|
|
}
|
|
|
|
function compileConnectors(model, lanes, diagnostics, overrides, junctionPlans) {
|
|
const features = [];
|
|
const movements = [];
|
|
for (const connection of model.connections.filter((item) => item.enabled)) {
|
|
const fromRoad = model.roads.find((road) => road.id === endpointRoadId(model, connection.fromEndpointId));
|
|
const toRoad = model.roads.find((road) => road.id === endpointRoadId(model, connection.toEndpointId));
|
|
const fromLanes = lanes.byRoadId.get(fromRoad?.id) || [];
|
|
const toLanes = lanes.byRoadId.get(endpointRoadId(model, connection.toEndpointId)) || [];
|
|
if (!fromLanes.length || !toLanes.length) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'warning',
|
|
connection.id,
|
|
[connection.nodeId],
|
|
'connector-missing-lane',
|
|
'转向连接缺少可用车道中心线。',
|
|
endpointCoordinate(model, connection.fromEndpointId),
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
for (let index = 0; index < fromLanes.length; index += 1) {
|
|
const turn = connectionTurn(fromRoad, toRoad);
|
|
const defaultTargetIndex = targetLaneIndex(turn, index, fromLanes.length, toLanes.length);
|
|
const defaultFromLane = fromLanes[index];
|
|
const defaultToLane = toLanes[defaultTargetIndex];
|
|
const override = laneOverride(overrides, defaultFromLane.id, defaultToLane.id);
|
|
if ((!laneAllowsTurn(fromRoad, index, turn) && override?.enabled !== true) || override?.enabled === false)
|
|
continue;
|
|
const from = defaultFromLane.coordinates.at(-1);
|
|
const to = defaultToLane.coordinates[0];
|
|
const plan = junctionPlans.get(connection.nodeId);
|
|
// Cross intersections retain the earlier center-node curve while T junctions
|
|
// use lane tangents so their through movement does not bow toward the stem.
|
|
const coordinates =
|
|
plan?.segmentIds.size === 4
|
|
? quadraticCurve(from, endpointCoordinate(model, connection.fromEndpointId), to, 12)
|
|
: connectorCurve(defaultFromLane.coordinates, defaultToLane.coordinates, turn);
|
|
const length = lineLengthMeters(coordinates);
|
|
const id = `movement:${connection.id}:${defaultFromLane.id}->${defaultToLane.id}`;
|
|
const provenance = override ? `override:${override.id}` : connection.provenance;
|
|
const connectorId = `connector:${id}`;
|
|
const geometryStatus = length < 0.4 ? 'continuous' : length > 80 ? 'deferred-too-long' : 'connector';
|
|
const movement = {
|
|
id,
|
|
connectorId,
|
|
connectionId: connection.id,
|
|
nodeId: connection.nodeId,
|
|
fromRoadId: fromRoad.id,
|
|
toRoadId: defaultToLane.roadId,
|
|
fromLaneId: defaultFromLane.id,
|
|
toLaneId: defaultToLane.id,
|
|
turn,
|
|
provenance,
|
|
appliedOverrideIds: override ? [override.id] : [],
|
|
geometryPublished: geometryStatus === 'connector',
|
|
geometryStatus,
|
|
};
|
|
if (length < 0.4) {
|
|
movements.push(movement);
|
|
continue;
|
|
}
|
|
if (length > 80) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'warning',
|
|
connection.id,
|
|
[connection.nodeId],
|
|
'connector-too-long',
|
|
'转向路径超过 80 米,未发布几何;请检查路口拓扑或人工连接。',
|
|
from,
|
|
),
|
|
);
|
|
movements.push(movement);
|
|
continue;
|
|
}
|
|
const cluster = plan?.clusterId || null;
|
|
features.push({
|
|
type: 'Feature',
|
|
properties: {
|
|
native_id: connectorId,
|
|
movement_id: id,
|
|
connection_id: connection.id,
|
|
node_id: connection.nodeId,
|
|
cluster_id: cluster,
|
|
cluster_internal: Boolean(cluster),
|
|
from_lane_id: defaultFromLane.id,
|
|
to_lane_id: defaultToLane.id,
|
|
turn,
|
|
provenance,
|
|
},
|
|
geometry: { type: 'LineString', coordinates },
|
|
});
|
|
movements.push(movement);
|
|
}
|
|
}
|
|
return { features, movements };
|
|
}
|
|
function laneOverride(overrides, fromLaneId, toLaneId) {
|
|
return overrides.overrides.find(
|
|
(item) => item.kind === 'lane-connection' && item.fromLaneId === fromLaneId && item.toLaneId === toLaneId,
|
|
);
|
|
}
|
|
|
|
function endpointRoadId(model, endpointId) {
|
|
return model.endpoints.find((endpoint) => endpoint.id === endpointId)?.roadId;
|
|
}
|
|
function endpointCoordinate(model, endpointId) {
|
|
return model.endpoints.find((endpoint) => endpoint.id === endpointId)?.coordinate;
|
|
}
|
|
function connectionTurn(fromRoad, toRoad) {
|
|
if (!fromRoad || !toRoad) return 'unknown';
|
|
const incoming = headingDegrees(fromRoad.centerline.at(-2), fromRoad.centerline.at(-1));
|
|
const outgoing = headingDegrees(toRoad.centerline[0], toRoad.centerline[1]);
|
|
const delta = ((outgoing - incoming + 540) % 360) - 180;
|
|
if (Math.abs(delta) >= 150) return 'uturn';
|
|
if (Math.abs(delta) <= 30) return 'through';
|
|
return delta > 0 ? 'right' : 'left';
|
|
}
|
|
function laneAllowsTurn(road, zeroIndex, turn) {
|
|
if (!road) return true;
|
|
const tag = road.tags[`turn:lanes:${road.direction}`] ?? road.tags['turn:lanes'];
|
|
if (!tag) return true;
|
|
const lanes = String(tag)
|
|
.split('|')
|
|
.map((lane) =>
|
|
lane
|
|
.split(';')
|
|
.map((value) => value.trim().replace('slight_', ''))
|
|
.filter(Boolean),
|
|
);
|
|
const allowed = lanes[zeroIndex];
|
|
return !allowed || allowed.includes(turn) || (turn === 'uturn' && allowed.includes('reverse'));
|
|
}
|
|
function targetLaneIndex(turn, sourceIndex, sourceCount, targetCount) {
|
|
if (turn === 'left') return 0;
|
|
if (turn === 'right') return targetCount - 1;
|
|
if (turn === 'uturn') return 0;
|
|
return Math.min(
|
|
targetCount - 1,
|
|
Math.round((sourceIndex / Math.max(1, sourceCount - 1)) * Math.max(0, targetCount - 1)),
|
|
);
|
|
}
|
|
function connectorCurve(incoming, outgoing, turn) {
|
|
const start = incoming.at(-1);
|
|
const end = outgoing[0];
|
|
if (turn === 'through') return lineCurve(start, end, 12);
|
|
const incomingHeading = headingDegrees(incoming.at(-2), start);
|
|
const outgoingHeading = headingDegrees(end, outgoing[1]);
|
|
const chord = distanceMeters(start, end);
|
|
const incomingSpan = distanceMeters(incoming.at(-2), start);
|
|
const outgoingSpan = distanceMeters(end, outgoing[1]);
|
|
const tangentIntersection = intersectTangentRays(start, end, incomingHeading, outgoingHeading);
|
|
const fallbackDistance = Math.min(8, Math.max(0.75, Math.min(chord * 0.42, incomingSpan * 0.8, outgoingSpan * 0.8)));
|
|
const firstDistance =
|
|
tangentIntersection && tangentIntersection.incoming >= 0
|
|
? Math.min(tangentIntersection.incoming, Math.min(8, Math.max(0.75, incomingSpan * 2.4))) / 3
|
|
: fallbackDistance;
|
|
const secondDistance =
|
|
tangentIntersection && tangentIntersection.outgoing >= 0
|
|
? Math.min(tangentIntersection.outgoing, Math.min(8, Math.max(0.75, outgoingSpan * 2.4))) / 3
|
|
: fallbackDistance;
|
|
const firstControl = offsetCoordinate(start, incomingHeading, firstDistance);
|
|
const secondControl = offsetCoordinate(end, outgoingHeading + 180, secondDistance);
|
|
return cubicBezier(start, firstControl, secondControl, end, 12);
|
|
}
|
|
|
|
function intersectTangentRays(start, end, incomingHeading, outgoingHeading) {
|
|
const incoming = headingVector(incomingHeading);
|
|
const outgoing = headingVector(outgoingHeading);
|
|
const delta = project(end, start);
|
|
const cross = incoming[0] * outgoing[1] - incoming[1] * outgoing[0];
|
|
if (Math.abs(cross) < 1e-6) return null;
|
|
return {
|
|
incoming: (delta[0] * outgoing[1] - delta[1] * outgoing[0]) / cross,
|
|
outgoing: (delta[0] * incoming[1] - delta[1] * incoming[0]) / cross,
|
|
};
|
|
}
|
|
|
|
function lineCurve(start, end, segments) {
|
|
return Array.from({ length: segments + 1 }, (_, index) => interpolate(start, end, index / segments));
|
|
}
|
|
|
|
function cubicBezier(a, firstControl, secondControl, b, segments) {
|
|
const result = [];
|
|
for (let index = 0; index <= segments; index += 1) {
|
|
const t = index / segments;
|
|
const u = 1 - t;
|
|
result.push([
|
|
u ** 3 * a[0] + 3 * u * u * t * firstControl[0] + 3 * u * t * t * secondControl[0] + t ** 3 * b[0],
|
|
u ** 3 * a[1] + 3 * u * u * t * firstControl[1] + 3 * u * t * t * secondControl[1] + t ** 3 * b[1],
|
|
]);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function quadraticCurve(a, control, b, segments) {
|
|
const result = [];
|
|
for (let index = 0; index <= segments; index += 1) {
|
|
const t = index / segments;
|
|
const u = 1 - t;
|
|
result.push([
|
|
u * u * a[0] + 2 * u * t * control[0] + t * t * b[0],
|
|
u * u * a[1] + 2 * u * t * control[1] + t * t * b[1],
|
|
]);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function offsetLine(line, offsetMeters) {
|
|
if (line.length < 2) return null;
|
|
const origin = line[0];
|
|
const points = line.map((point) => project(point, origin));
|
|
const result = [];
|
|
for (let index = 0; index < points.length; index += 1) {
|
|
const previous = points[Math.max(0, index - 1)];
|
|
const next = points[Math.min(points.length - 1, index + 1)];
|
|
const dx = next[0] - previous[0];
|
|
const dy = next[1] - previous[1];
|
|
const length = Math.hypot(dx, dy);
|
|
if (length < 0.01) return null;
|
|
result.push(
|
|
unproject(
|
|
[points[index][0] - (dy / length) * offsetMeters, points[index][1] + (dx / length) * offsetMeters],
|
|
origin,
|
|
),
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function lineLengthMeters(line) {
|
|
return line.slice(1).reduce((sum, point, index) => {
|
|
const previous = line[index];
|
|
const dx = (point[0] - previous[0]) * 111320 * Math.cos((point[1] * Math.PI) / 180);
|
|
const dy = (point[1] - previous[1]) * 111320;
|
|
return sum + Math.hypot(dx, dy);
|
|
}, 0);
|
|
}
|
|
function polygonAreaMeters(ring) {
|
|
if (ring.length < 3) return 0;
|
|
const origin = ring[0];
|
|
const points = ring.map((point) => project(point, origin));
|
|
let twiceArea = 0;
|
|
for (let index = 0; index < points.length; index += 1) {
|
|
const next = points[(index + 1) % points.length];
|
|
twiceArea += points[index][0] * next[1] - next[0] * points[index][1];
|
|
}
|
|
return Math.abs(twiceArea) / 2;
|
|
}
|
|
|
|
function compileJunctionSurfaces(model, junctionPlans, connectors, movements, diagnostics, options = {}) {
|
|
const result = [];
|
|
const complexClusters = new Set(
|
|
(options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : [])
|
|
.filter((cluster) => cluster.template === 'complex-junction-v1')
|
|
.map((cluster) => cluster.id),
|
|
);
|
|
for (const [nodeId, plan] of junctionPlans) {
|
|
if (plan.clusterId && complexClusters.has(plan.clusterId)) continue;
|
|
const { segmentIds, node, approaches, cutbackMeters, boundary } = plan;
|
|
const junctionConnectors = connectors.filter((feature) => feature.properties.node_id === nodeId);
|
|
const junctionMovements = movements.filter((movement) => movement.nodeId === nodeId);
|
|
if (boundary.length < 3 || !junctionMovements.length) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'warning',
|
|
`junction:node/${nodeId}`,
|
|
[nodeId],
|
|
'junction-surface-deferred',
|
|
'路口缺少足够的截面或转向路径,暂不生成路口面。',
|
|
node,
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
const approachAreaMeters = polygonAreaMeters(boundary);
|
|
let ring = [...boundary, boundary[0]];
|
|
let boundaryMode = plan.boundaryMode || 'approach-envelope';
|
|
if (
|
|
hasSelfIntersection(ring) ||
|
|
!junctionConnectors.every((feature) =>
|
|
feature.geometry.coordinates.every((point) =>
|
|
pointInOrNearPolygon(point, ring, CONNECTOR_BOUNDARY_TOLERANCE_METERS),
|
|
),
|
|
)
|
|
) {
|
|
const envelope = convexHull([
|
|
...boundary,
|
|
...junctionConnectors.flatMap((feature) => feature.geometry.coordinates),
|
|
]);
|
|
ring = [...envelope, envelope[0]];
|
|
boundaryMode = 'connector-convex-fallback';
|
|
}
|
|
if (hasSelfIntersection(ring)) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'error',
|
|
`junction:node/${nodeId}`,
|
|
[nodeId],
|
|
'invalid-junction-surface',
|
|
'路口截面边界发生自相交,未发布路口面。请检查道路方向或路口拓扑。',
|
|
node,
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
const surfaceAreaMeters = polygonAreaMeters(ring);
|
|
const expansionRatio = approachAreaMeters > 0 ? surfaceAreaMeters / approachAreaMeters : null;
|
|
result.push({
|
|
type: 'Feature',
|
|
properties: {
|
|
native_id: `junction:node/${nodeId}`,
|
|
osm_node_id: nodeId,
|
|
kind: segmentIds.size === 3 ? 't' : 'cross',
|
|
source_road_ids: approaches.flatMap((approach) => approach.roadIds).join(','),
|
|
cutback_m: cutbackMeters,
|
|
movement_count: junctionMovements.length,
|
|
connector_count: junctionConnectors.length,
|
|
boundary_mode: boundaryMode,
|
|
template: plan.template || null,
|
|
template_reference: plan.templateReference || null,
|
|
approach_area_m2: Math.round(approachAreaMeters * 10) / 10,
|
|
surface_area_m2: Math.round(surfaceAreaMeters * 10) / 10,
|
|
expansion_ratio: expansionRatio === null ? null : Math.round(expansionRatio * 100) / 100,
|
|
rule: plan.template ? 'junction-cross-template/v1' : 'junction-shared-cutback/v4-shared-node-split',
|
|
},
|
|
geometry: { type: 'Polygon', coordinates: [ring] },
|
|
});
|
|
if (boundaryMode === 'connector-convex-fallback')
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'warning',
|
|
`junction:node/${nodeId}`,
|
|
[nodeId],
|
|
'junction-connector-envelope-fallback',
|
|
'路口面需要按转向路径的凸包兜底生成;请检查外缘和路缘与步行带是否符合实际。',
|
|
node,
|
|
),
|
|
);
|
|
if (plan.boundaryFallbacks)
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'warning',
|
|
`junction:node/${nodeId}`,
|
|
[nodeId],
|
|
'junction-rounded-corner-fallback',
|
|
'部分路口圆角无法按道路边缘切线安全构造,已对该角使用确定性的直线回退。',
|
|
node,
|
|
),
|
|
);
|
|
if (!plan.clusterId)
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'info',
|
|
`junction:node/${nodeId}`,
|
|
[nodeId],
|
|
'ordinary-junction-surface',
|
|
'已按道路截面与转向路径生成普通路口面。',
|
|
node,
|
|
),
|
|
);
|
|
}
|
|
for (const cluster of options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : []) {
|
|
if (cluster.template === 'complex-junction-v1') continue;
|
|
const clusterNodes = new Set(cluster.nodeIds.map(String));
|
|
const members = result.filter((feature) => clusterNodes.has(String(feature.properties.osm_node_id)));
|
|
if (members.length < 2) continue;
|
|
const points = [];
|
|
const clusterCoordinates = [...clusterNodes].map((nodeId) => junctionPlans.get(nodeId)?.node).filter(Boolean);
|
|
const clusterCenter = clusterCoordinates.reduce(
|
|
(sum, point) => [sum[0] + point[0] / clusterCoordinates.length, sum[1] + point[1] / clusterCoordinates.length],
|
|
[0, 0],
|
|
);
|
|
for (let index = 0; index < 8; index += 1) points.push(offsetCoordinate(clusterCenter, index * 45, 12));
|
|
for (const [nodeId, plan] of junctionPlans) {
|
|
if (!clusterNodes.has(String(nodeId))) continue;
|
|
for (const approach of plan.approaches) {
|
|
const end = approach.line.at(-1);
|
|
if (
|
|
[...clusterNodes].some(
|
|
(candidate) =>
|
|
candidate !== String(nodeId) &&
|
|
distanceMeters(end, junctionPlans.get(candidate)?.node || [Infinity, Infinity]) < 3,
|
|
)
|
|
)
|
|
continue;
|
|
const cutback = pointAlongLine(
|
|
approach.line,
|
|
Math.min(plan.cutbackMeters, Math.max(12, cluster.approachLengthMeters * 0.5)),
|
|
);
|
|
const heading = headingAtEndpoint(approach.line);
|
|
const half = approach.widthMeters / 2;
|
|
points.push(offsetCoordinate(cutback, heading + 90, half), offsetCoordinate(cutback, heading - 90, half));
|
|
}
|
|
const node = plan.node;
|
|
for (let index = 0; index < 8; index += 1) points.push(offsetCoordinate(node, index * 45, 9));
|
|
}
|
|
const hull = convexHull(points);
|
|
if (hull.length < 3) continue;
|
|
const ring = roundedHull(hull, 0.22);
|
|
const memberIds = new Set(members.map((feature) => feature.properties.native_id));
|
|
for (let index = result.length - 1; index >= 0; index -= 1)
|
|
if (memberIds.has(result[index].properties.native_id)) result.splice(index, 1);
|
|
result.push({
|
|
type: 'Feature',
|
|
properties: {
|
|
native_id: `junction-cluster:${cluster.id}`,
|
|
osm_node_ids: [...clusterNodes].join(','),
|
|
kind: 'cluster',
|
|
template: cluster.template,
|
|
boundary_mode: 'cluster-import-core',
|
|
center: clusterCenter,
|
|
member_count: members.length,
|
|
movement_count: movements.filter((movement) => clusterNodes.has(String(movement.nodeId))).length,
|
|
connector_count: connectors.filter((feature) => clusterNodes.has(String(feature.properties.node_id))).length,
|
|
surface_area_m2: Math.round(polygonAreaMeters(ring) * 10) / 10,
|
|
rule: 'junction-cluster-template/v2',
|
|
},
|
|
geometry: { type: 'Polygon', coordinates: [[...ring, ring[0]]] },
|
|
});
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'info',
|
|
`junction-cluster:${cluster.id}`,
|
|
[...clusterNodes],
|
|
'junction-cluster-core-applied',
|
|
'已按外部进口截面和簇节点核心生成受限复合路口面。',
|
|
ring[0],
|
|
),
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function activeComplexCluster(options, clusterId) {
|
|
return Boolean(
|
|
clusterId &&
|
|
(options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : []).some(
|
|
(cluster) => cluster.id === clusterId && cluster.template === 'complex-junction-v1',
|
|
),
|
|
);
|
|
}
|
|
|
|
function compileJunctionPlans(model, options = {}, diagnostics = []) {
|
|
const byNode = new Map();
|
|
for (const endpoint of model.endpoints) {
|
|
if (!byNode.has(endpoint.nodeId)) byNode.set(endpoint.nodeId, []);
|
|
byNode.get(endpoint.nodeId).push(endpoint);
|
|
}
|
|
const plans = new Map();
|
|
const clusters = options.junctionTemplates?.enabled ? options.junctionTemplates.clusters || [] : [];
|
|
const clusterByNode = new Map(
|
|
clusters.flatMap((cluster) => cluster.nodeIds.map((nodeId) => [String(nodeId), cluster])),
|
|
);
|
|
for (const [nodeId, endpoints] of byNode) {
|
|
const segmentIds = new Set(endpoints.map((endpoint) => endpoint.roadId.replace(/:(forward|backward)$/, '')));
|
|
if (segmentIds.size < 3 || segmentIds.size > 4) continue;
|
|
const approaches = junctionApproaches(model, endpoints);
|
|
if (approaches.length !== segmentIds.size) continue;
|
|
// Rounded curb corners need enough approach length to retain the full
|
|
// turning envelope after the corner is cut toward the junction.
|
|
const baseCutbackMeters = Math.max(...approaches.map((approach) => approach.widthMeters)) * 1.4;
|
|
const node = endpoints[0].coordinate;
|
|
const template = junctionTemplateFor(nodeId, segmentIds, options.junctionTemplates, diagnostics, node);
|
|
const cutbackMeters = baseCutbackMeters * (template?.cutbackMultiplier || 1);
|
|
const boundary = junctionBoundary(
|
|
approaches,
|
|
node,
|
|
cutbackMeters,
|
|
template?.cornerRadiusMultiplier || 1,
|
|
template?.approachWidthMultiplier || 1,
|
|
);
|
|
if (boundary.points.length < 3) continue;
|
|
const cluster = clusterByNode.get(String(nodeId));
|
|
plans.set(nodeId, {
|
|
node,
|
|
segmentIds,
|
|
approaches,
|
|
cutbackMeters,
|
|
boundary: boundary.points,
|
|
boundaryMode: boundary.mode,
|
|
boundaryFallbacks: boundary.fallbacks,
|
|
template: template?.template || null,
|
|
templateReference: template?.referenceFile || null,
|
|
approachWidthMultiplier: template?.approachWidthMultiplier || 1,
|
|
approachLengthMeters: template?.approachLengthMeters || 0,
|
|
clusterId: cluster?.id || null,
|
|
});
|
|
}
|
|
return plans;
|
|
}
|
|
|
|
function junctionApproaches(model, endpoints) {
|
|
const groups = new Map();
|
|
for (const endpoint of endpoints) {
|
|
const road = model.roads.find((item) => item.id === endpoint.roadId);
|
|
if (!road) continue;
|
|
const key = road.segmentId;
|
|
if (!groups.has(key)) groups.set(key, []);
|
|
groups.get(key).push({ endpoint, road });
|
|
}
|
|
return [...groups.values()].map((directions) => {
|
|
const { endpoint, road } = directions[0];
|
|
return {
|
|
segmentId: road.segmentId,
|
|
sourceWayKey: road.osmWayIds.join(','),
|
|
line: endpoint.side === 'end' ? [...road.centerline].reverse() : road.centerline,
|
|
roadIds: directions.map((item) => item.road.id),
|
|
widthMeters: directions.reduce((sum, item) => sum + item.road.widthMeters, 0),
|
|
};
|
|
});
|
|
}
|
|
|
|
// `roadRing` builds the trimmed road's end edge from the direction at the
|
|
// cutback point, not at the node. Taking the node-side heading here instead
|
|
// leaves the two edges non-parallel whenever the way bends inside the cutback,
|
|
// and the junction surface then opens a wedge against the road it should meet.
|
|
function headingAtCutback(line, cutbackMeters) {
|
|
const point = pointAlongLine(line, cutbackMeters);
|
|
if (!point) return null;
|
|
let traversed = 0;
|
|
for (let index = 1; index < line.length; index += 1) {
|
|
traversed += distanceMeters(line[index - 1], line[index]);
|
|
// The first original vertex past the cutback is what the trimmed line
|
|
// carries as its second point, so match that pair exactly.
|
|
if (traversed > cutbackMeters + 1e-9) return headingDegrees(point, line[index]);
|
|
}
|
|
return headingAtEndpoint(line);
|
|
}
|
|
|
|
// A dual carriageway reaches a node as two approaches on almost the same
|
|
// bearing. Their four side points interleave once sorted by angle, and because
|
|
// consecutive points then belong to different segments a rounded corner gets
|
|
// inserted between each interleaved pair. Those curves dive back toward the
|
|
// node and render as arch-shaped holes in the junction. Merge such approaches
|
|
// into one face and keep only its outermost edges.
|
|
const PARALLEL_APPROACH_DEGREES = 25;
|
|
|
|
function signedHeadingDelta(value) {
|
|
return ((((value + 180) % 360) + 360) % 360) - 180;
|
|
}
|
|
|
|
function mergeParallelApproachPoints(points, node) {
|
|
const groups = [];
|
|
for (const item of points) {
|
|
const group = groups.find(
|
|
(candidate) => Math.abs(signedHeadingDelta(candidate.heading - item.outwardHeading)) < PARALLEL_APPROACH_DEGREES,
|
|
);
|
|
if (group) {
|
|
group.points.push(item);
|
|
continue;
|
|
}
|
|
groups.push({ heading: item.outwardHeading, points: [item] });
|
|
}
|
|
return groups.flatMap((group) => {
|
|
const segments = [...new Set(group.points.map((item) => item.segmentId))];
|
|
if (segments.length < 2) return group.points;
|
|
// Order across the face by bearing measured from the group's own heading,
|
|
// so the comparison never straddles the +/-180 discontinuity.
|
|
const sorted = [...group.points].sort(
|
|
(first, second) =>
|
|
signedHeadingDelta(headingDegrees(node, first.point) - group.heading) -
|
|
signedHeadingDelta(headingDegrees(node, second.point) - group.heading),
|
|
);
|
|
const merged = segments.sort().join('+');
|
|
return [sorted[0], sorted.at(-1)].map((item) => ({ ...item, segmentId: merged, sourceWayKey: merged }));
|
|
});
|
|
}
|
|
|
|
function junctionBoundary(approaches, node, cutbackMeters, cornerRadiusMultiplier = 1, approachWidthMultiplier = 1) {
|
|
const points = [];
|
|
for (const approach of approaches) {
|
|
const cutback = pointAlongLine(approach.line, cutbackMeters);
|
|
if (!cutback) continue;
|
|
const heading = headingAtCutback(approach.line, cutbackMeters) ?? headingAtEndpoint(approach.line);
|
|
const half = (approach.widthMeters * approachWidthMultiplier) / 2;
|
|
points.push({
|
|
point: offsetCoordinate(cutback, heading + 90, half),
|
|
segmentId: approach.segmentId,
|
|
sourceWayKey: approach.sourceWayKey,
|
|
outwardHeading: heading,
|
|
});
|
|
points.push({
|
|
point: offsetCoordinate(cutback, heading - 90, half),
|
|
segmentId: approach.segmentId,
|
|
sourceWayKey: approach.sourceWayKey,
|
|
outwardHeading: heading,
|
|
});
|
|
}
|
|
const faces = mergeParallelApproachPoints(points, node);
|
|
const ordered = faces.sort((a, b) => angleAround(node, a.point) - angleAround(node, b.point));
|
|
if (ordered.length < 3) return { points: [], mode: 'approach-envelope' };
|
|
const boundary = [];
|
|
let rounded = 0;
|
|
let fallbacks = 0;
|
|
for (let index = 0; index < ordered.length; index += 1) {
|
|
const first = ordered[index];
|
|
const second = ordered[(index + 1) % ordered.length];
|
|
boundary.push(first.point);
|
|
// One physical OSM way is often split at an intersection node. Its two
|
|
// opposite approaches share a continuous road edge; rounding that edge
|
|
// bends the far side of a T junction and exposes junction asphalt beyond
|
|
// the pedestrian strip.
|
|
if (first.segmentId === second.segmentId || isStraightJunctionEdge(first, second)) continue;
|
|
const curve = roundedCorner(
|
|
node,
|
|
first.point,
|
|
second.point,
|
|
first.outwardHeading,
|
|
second.outwardHeading,
|
|
cornerRadiusMultiplier,
|
|
);
|
|
if (!curve) {
|
|
fallbacks += 1;
|
|
continue;
|
|
}
|
|
boundary.push(...curve.slice(1, -1));
|
|
rounded += 1;
|
|
}
|
|
return { points: boundary, mode: rounded ? 'rounded-approach-envelope' : 'approach-envelope', fallbacks };
|
|
}
|
|
|
|
function templateApproachRing(approach, plan) {
|
|
const innerDistance = plan.cutbackMeters;
|
|
const availableLength = lineLengthMeters(approach.line) - innerDistance - 0.5;
|
|
const lengthMeters = Math.min(plan.approachLengthMeters, availableLength);
|
|
if (lengthMeters < 10) return null;
|
|
const outerDistance = innerDistance + lengthMeters;
|
|
const inner = pointAlongLine(approach.line, innerDistance);
|
|
const outer = pointAlongLine(approach.line, outerDistance);
|
|
const heading = headingAtEndpoint(approach.line);
|
|
const innerHalf = (approach.widthMeters * plan.approachWidthMultiplier) / 2;
|
|
const outerHalf = approach.widthMeters / 2;
|
|
const ring = [
|
|
offsetCoordinate(outer, heading + 90, outerHalf),
|
|
offsetCoordinate(inner, heading + 90, innerHalf),
|
|
offsetCoordinate(inner, heading - 90, innerHalf),
|
|
offsetCoordinate(outer, heading - 90, outerHalf),
|
|
offsetCoordinate(outer, heading + 90, outerHalf),
|
|
];
|
|
return ring.every((point) => point.every(Number.isFinite)) ? { ring, lengthMeters } : null;
|
|
}
|
|
|
|
function roundedHull(hull, factor) {
|
|
const result = [];
|
|
for (let index = 0; index < hull.length; index += 1) {
|
|
const previous = hull[(index - 1 + hull.length) % hull.length];
|
|
const current = hull[index];
|
|
const next = hull[(index + 1) % hull.length];
|
|
const entry = interpolate(previous, current, factor);
|
|
const exit = interpolate(current, next, factor);
|
|
result.push(entry);
|
|
const curve = quadraticCurve(entry, current, exit, 4);
|
|
result.push(...curve.slice(1, -1));
|
|
result.push(exit);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function isStraightJunctionEdge(first, second) {
|
|
if (first.sourceWayKey !== second.sourceWayKey) return false;
|
|
const radians = ((first.outwardHeading - second.outwardHeading) * Math.PI) / 180;
|
|
return Math.cos(radians) <= -0.98;
|
|
}
|
|
|
|
function roundedCorner(node, first, second, firstHeading, secondHeading, radiusMultiplier = 1) {
|
|
const origin = node;
|
|
const a = project(first, origin);
|
|
const b = project(second, origin);
|
|
const chord = Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
if (chord < 0.5 || !Number.isFinite(firstHeading) || !Number.isFinite(secondHeading)) return null;
|
|
const firstDirection = headingVector(firstHeading);
|
|
const secondDirection = headingVector(secondHeading);
|
|
const intersection = lineIntersection(a, firstDirection, b, secondDirection);
|
|
if (!intersection) return null;
|
|
const controlDistance = Math.hypot(...intersection);
|
|
const endpointDistance = Math.max(Math.hypot(...a), Math.hypot(...b));
|
|
// Adjacent approach edge tangents should meet in the corner between the
|
|
// node and the cutback. Reject near-parallel or remote intersections rather
|
|
// than publishing a huge/self-crossing curve.
|
|
if (controlDistance < 0.01 || controlDistance > endpointDistance * 1.5 || controlDistance > 80) return null;
|
|
const scaledIntersection = [intersection[0] * radiusMultiplier, intersection[1] * radiusMultiplier];
|
|
const control = unproject(scaledIntersection, origin);
|
|
return quadraticCurve(first, control, second, JUNCTION_CURVE_SEGMENTS);
|
|
}
|
|
|
|
function junctionTemplateFor(nodeId, segmentIds, configured, diagnostics, node) {
|
|
if (!configured?.enabled) return null;
|
|
const entry = (configured.references || []).find((item) => String(item.nodeId) === String(nodeId));
|
|
if (!entry) return null;
|
|
if (segmentIds.size !== 4) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'info',
|
|
`junction:node/${nodeId}`,
|
|
[nodeId],
|
|
'junction-template-topology-skip',
|
|
'cross 模板只应用于四臂路口,当前路口保留 native 几何。',
|
|
node,
|
|
),
|
|
);
|
|
return null;
|
|
}
|
|
if (entry.template !== 'cross-v1') {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'warning',
|
|
`junction:node/${nodeId}`,
|
|
[nodeId],
|
|
'junction-template-unsupported',
|
|
'路口模板名称不受支持,已回退 native 几何。',
|
|
node,
|
|
),
|
|
);
|
|
return null;
|
|
}
|
|
const multiplier = Number(entry.cornerRadiusMultiplier ?? 1);
|
|
if (!Number.isFinite(multiplier) || multiplier < 0.75 || multiplier > 1.25) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'warning',
|
|
`junction:node/${nodeId}`,
|
|
[nodeId],
|
|
'junction-template-invalid-parameter',
|
|
'cross 模板圆角参数必须在 0.75 到 1.25 之间,已回退 native 几何。',
|
|
node,
|
|
),
|
|
);
|
|
return null;
|
|
}
|
|
const cutbackMultiplier = Number(entry.cutbackMultiplier ?? 1);
|
|
if (!Number.isFinite(cutbackMultiplier) || cutbackMultiplier < 1 || cutbackMultiplier > 1.35) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'warning',
|
|
`junction:node/${nodeId}`,
|
|
[nodeId],
|
|
'junction-template-invalid-parameter',
|
|
'cross 模板进口过渡参数必须在 1 到 1.35 之间,已回退 native 几何。',
|
|
node,
|
|
),
|
|
);
|
|
return null;
|
|
}
|
|
const approachWidthMultiplier = Number(entry.approachWidthMultiplier ?? 1);
|
|
if (!Number.isFinite(approachWidthMultiplier) || approachWidthMultiplier < 1 || approachWidthMultiplier > 1.8) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'warning',
|
|
`junction:node/${nodeId}`,
|
|
[nodeId],
|
|
'junction-template-invalid-parameter',
|
|
'cross 模板进口宽度参数必须在 1 到 1.8 之间,已回退 native 几何。',
|
|
node,
|
|
),
|
|
);
|
|
return null;
|
|
}
|
|
const approachLengthMeters = Number(entry.approachLengthMeters ?? 24);
|
|
if (!Number.isFinite(approachLengthMeters) || approachLengthMeters < 10 || approachLengthMeters > 50) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'warning',
|
|
`junction:node/${nodeId}`,
|
|
[nodeId],
|
|
'junction-template-invalid-parameter',
|
|
'cross 模板进口过渡长度必须在 10 到 50 米之间,已回退 native 几何。',
|
|
node,
|
|
),
|
|
);
|
|
return null;
|
|
}
|
|
if (entry.referenceFile && !fs.existsSync(entry.referenceFile)) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'warning',
|
|
`junction:node/${nodeId}`,
|
|
[nodeId],
|
|
'junction-template-reference-missing',
|
|
'路口参考文件不存在,已回退 native 几何。',
|
|
node,
|
|
),
|
|
);
|
|
return null;
|
|
}
|
|
diagnostics.push(
|
|
diagnostic(
|
|
'info',
|
|
`junction:node/${nodeId}`,
|
|
[nodeId],
|
|
'junction-template-applied',
|
|
'已按 cross-v1 模板规整路口面;道路、车道、连接器和控制设施保持 native 结果。',
|
|
node,
|
|
),
|
|
);
|
|
return {
|
|
template: entry.template,
|
|
referenceFile: entry.referenceFile || null,
|
|
cornerRadiusMultiplier: multiplier,
|
|
cutbackMultiplier,
|
|
approachWidthMultiplier,
|
|
approachLengthMeters,
|
|
};
|
|
}
|
|
|
|
function headingVector(degrees) {
|
|
const radians = (degrees * Math.PI) / 180;
|
|
return [Math.sin(radians), Math.cos(radians)];
|
|
}
|
|
|
|
function lineIntersection(firstPoint, firstDirection, secondPoint, secondDirection) {
|
|
const cross = firstDirection[0] * secondDirection[1] - firstDirection[1] * secondDirection[0];
|
|
if (Math.abs(cross) < 1e-4) return null;
|
|
const delta = [secondPoint[0] - firstPoint[0], secondPoint[1] - firstPoint[1]];
|
|
const firstDistance = (delta[0] * secondDirection[1] - delta[1] * secondDirection[0]) / cross;
|
|
return [firstPoint[0] + firstDirection[0] * firstDistance, firstPoint[1] + firstDirection[1] * firstDistance];
|
|
}
|
|
|
|
function pointAlongLine(line, meters) {
|
|
let remaining = meters;
|
|
for (let index = 1; index < line.length; index += 1) {
|
|
const length = distanceMeters(line[index - 1], line[index]);
|
|
if (length >= remaining) return interpolate(line[index - 1], line[index], remaining / length);
|
|
remaining -= length;
|
|
}
|
|
return line.at(-1);
|
|
}
|
|
|
|
function pointAndAxisAlongLine(line, meters) {
|
|
let remaining = meters;
|
|
for (let index = 1; index < line.length; index += 1) {
|
|
const start = line[index - 1];
|
|
const end = line[index];
|
|
const length = distanceMeters(start, end);
|
|
if (length < 0.01) continue;
|
|
if (length >= remaining) {
|
|
const vector = project(end, start);
|
|
return { point: interpolate(start, end, remaining / length), axis: [vector[0] / length, vector[1] / length] };
|
|
}
|
|
remaining -= length;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function trimLineAtJunctions(line, sourceNodeIds, junctionPlans) {
|
|
const startCutback = junctionPlans.get(sourceNodeIds[0])?.cutbackMeters || 0;
|
|
const endCutback = junctionPlans.get(sourceNodeIds.at(-1))?.cutbackMeters || 0;
|
|
if (!startCutback && !endCutback) return line;
|
|
const total = lineLengthMeters(line);
|
|
// Short OSM fragments cannot safely lose both ends. Keep their source
|
|
// geometry intact and let the junction diagnostic surface the ambiguity.
|
|
if (startCutback + endCutback >= total - 0.5) return line;
|
|
const result = [];
|
|
let traversed = 0;
|
|
const start = pointAlongLine(line, startCutback);
|
|
const end = pointAlongLine(line, total - endCutback);
|
|
result.push(start);
|
|
for (let index = 1; index < line.length - 1; index += 1) {
|
|
traversed += distanceMeters(line[index - 1], line[index]);
|
|
if (traversed > startCutback && traversed < total - endCutback) result.push(line[index]);
|
|
}
|
|
result.push(end);
|
|
return result;
|
|
}
|
|
|
|
function trimLineAtComplexCluster(line, sourceNodeIds, junctionPlans, cluster, center) {
|
|
if (!center || line.length < 2) return trimLineAtJunctions(line, sourceNodeIds, junctionPlans);
|
|
const boundaryRadius = complexJunctionMetrics(cluster).approachOuterRadius;
|
|
const startInCluster = cluster.nodeIds.map(String).includes(String(sourceNodeIds[0]));
|
|
const endInCluster = cluster.nodeIds.map(String).includes(String(sourceNodeIds.at(-1)));
|
|
const available = lineLengthMeters(line);
|
|
const startDistance = startInCluster ? distanceAlongLineToRadius(line, center, boundaryRadius) : 0;
|
|
const endDistance = endInCluster ? distanceAlongLineToRadius([...line].reverse(), center, boundaryRadius) : 0;
|
|
const startCutback = startDistance > 0 && available > startDistance + 1 ? startDistance : 0;
|
|
const endCutback = endDistance > 0 && available > endDistance + 1 ? endDistance : 0;
|
|
if (!startCutback && !endCutback) return line;
|
|
return trimLineRange(line, startCutback, endCutback);
|
|
}
|
|
|
|
function distanceAlongLineToRadius(line, center, radius) {
|
|
if (!center || line.length < 2) return 0;
|
|
const heading = headingAtEndpoint(line);
|
|
const vector = project(line[0], center);
|
|
const radians = (heading * Math.PI) / 180;
|
|
const startRadius = vector[0] * Math.sin(radians) + vector[1] * Math.cos(radians);
|
|
return Math.max(0, radius - startRadius);
|
|
}
|
|
|
|
function trimLineRange(line, startCutback, endCutback) {
|
|
const total = lineLengthMeters(line);
|
|
if (startCutback + endCutback >= total - 0.5) return line;
|
|
const result = [pointAlongLine(line, startCutback)];
|
|
let traversed = 0;
|
|
for (let index = 1; index < line.length - 1; index += 1) {
|
|
traversed += distanceMeters(line[index - 1], line[index]);
|
|
if (traversed > startCutback && traversed < total - endCutback) result.push(line[index]);
|
|
}
|
|
result.push(pointAlongLine(line, total - endCutback));
|
|
return result;
|
|
}
|
|
|
|
function headingAtEndpoint(line) {
|
|
return headingDegrees(line[0], line[1]);
|
|
}
|
|
function headingDegrees(a, b) {
|
|
return (Math.atan2((b[0] - a[0]) * Math.cos((a[1] * Math.PI) / 180), b[1] - a[1]) * 180) / Math.PI;
|
|
}
|
|
function offsetCoordinate(point, degrees, meters) {
|
|
const radians = (degrees * Math.PI) / 180;
|
|
return [
|
|
point[0] + (Math.sin(radians) * meters) / (111320 * Math.cos((point[1] * Math.PI) / 180)),
|
|
point[1] + (Math.cos(radians) * meters) / 111320,
|
|
];
|
|
}
|
|
function angleAround(center, point) {
|
|
return Math.atan2(point[1] - center[1], point[0] - center[0]);
|
|
}
|
|
function sortAround(center, points) {
|
|
return points.sort(
|
|
(a, b) => Math.atan2(a[1] - center[1], a[0] - center[0]) - Math.atan2(b[1] - center[1], b[0] - center[0]),
|
|
);
|
|
}
|
|
function convexHull(points) {
|
|
const unique = [...new Map(points.map((point) => [`${point[0]},${point[1]}`, point])).values()].sort(
|
|
(a, b) => a[0] - b[0] || a[1] - b[1],
|
|
);
|
|
if (unique.length < 3) return unique;
|
|
const cross = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
|
const lower = [];
|
|
for (const point of unique) {
|
|
while (lower.length >= 2 && cross(lower.at(-2), lower.at(-1), point) <= 0) lower.pop();
|
|
lower.push(point);
|
|
}
|
|
const upper = [];
|
|
for (const point of [...unique].reverse()) {
|
|
while (upper.length >= 2 && cross(upper.at(-2), upper.at(-1), point) <= 0) upper.pop();
|
|
upper.push(point);
|
|
}
|
|
return [...lower.slice(0, -1), ...upper.slice(0, -1)];
|
|
}
|
|
function interpolate(a, b, ratio) {
|
|
return [a[0] + (b[0] - a[0]) * ratio, a[1] + (b[1] - a[1]) * ratio];
|
|
}
|
|
function distanceMeters(a, b) {
|
|
const dx = (b[0] - a[0]) * 111320 * Math.cos((a[1] * Math.PI) / 180);
|
|
const dy = (b[1] - a[1]) * 111320;
|
|
return Math.hypot(dx, dy);
|
|
}
|
|
function hasSelfIntersection(ring) {
|
|
for (let first = 0; first < ring.length - 1; first += 1)
|
|
for (let second = first + 1; second < ring.length - 1; second += 1) {
|
|
if (Math.abs(first - second) <= 1 || (first === 0 && second === ring.length - 2)) continue;
|
|
if (segmentsIntersect(ring[first], ring[first + 1], ring[second], ring[second + 1])) return true;
|
|
}
|
|
return false;
|
|
}
|
|
function segmentsIntersect(a, b, c, d) {
|
|
const cross = (p, q, r) => (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]);
|
|
const abC = cross(a, b, c);
|
|
const abD = cross(a, b, d);
|
|
const cdA = cross(c, d, a);
|
|
const cdB = cross(c, d, b);
|
|
return ((abC > 0 && abD < 0) || (abC < 0 && abD > 0)) && ((cdA > 0 && cdB < 0) || (cdA < 0 && cdB > 0));
|
|
}
|
|
|
|
function circleRing(center, radius, segments) {
|
|
const origin = center;
|
|
const ring = [];
|
|
for (let index = 0; index <= segments; index += 1) {
|
|
const angle = (index / segments) * Math.PI * 2;
|
|
ring.push(unproject([Math.cos(angle) * radius, Math.sin(angle) * radius], origin));
|
|
}
|
|
return ring;
|
|
}
|
|
|
|
// Offsetting every vertex by a fixed distance along its averaged normal has no
|
|
// miter limit: where the centerline turns and the neighbouring segment is short
|
|
// — typically the stub left after junction trimming — consecutive offset points
|
|
// swap order and the edge doubles back. The ring then self-intersects and the
|
|
// folded lobe renders as a hole. Drop the reversed vertices so each offset
|
|
// edge keeps travelling the same way as the centerline segment it follows.
|
|
function removeOffsetFolds(offset, centerline) {
|
|
let kept = offset.map((point, index) => ({ point, index }));
|
|
for (let guard = 0; guard < offset.length && kept.length > 2; guard += 1) {
|
|
let removed = false;
|
|
for (let position = 0; position < kept.length - 1; position += 1) {
|
|
const from = kept[position];
|
|
const to = kept[position + 1];
|
|
const alongCenter = [
|
|
centerline[to.index][0] - centerline[from.index][0],
|
|
centerline[to.index][1] - centerline[from.index][1],
|
|
];
|
|
const alongOffset = [to.point[0] - from.point[0], to.point[1] - from.point[1]];
|
|
if (alongCenter[0] * alongOffset[0] + alongCenter[1] * alongOffset[1] >= 0) continue;
|
|
// Keep both termini: they are where the surface meets its junctions.
|
|
kept.splice(position + 1 === kept.length - 1 ? position : position + 1, 1);
|
|
removed = true;
|
|
break;
|
|
}
|
|
if (!removed) break;
|
|
}
|
|
return kept.map((item) => item.point);
|
|
}
|
|
|
|
function roadRing(line, width) {
|
|
if (line.length < 2 || !Number.isFinite(width)) return null;
|
|
const origin = line[0];
|
|
const points = line.map((point) => project(point, origin));
|
|
const left = [];
|
|
const right = [];
|
|
const half = width / 2;
|
|
for (let i = 0; i < points.length; i += 1) {
|
|
const prior = points[Math.max(0, i - 1)];
|
|
const next = points[Math.min(points.length - 1, i + 1)];
|
|
const dx = next[0] - prior[0];
|
|
const dy = next[1] - prior[1];
|
|
const length = Math.hypot(dx, dy);
|
|
if (length < 0.01) return null;
|
|
const nx = (-dy / length) * half;
|
|
const ny = (dx / length) * half;
|
|
left.push([points[i][0] + nx, points[i][1] + ny]);
|
|
right.push([points[i][0] - nx, points[i][1] - ny]);
|
|
}
|
|
const leftEdge = removeOffsetFolds(left, points).map((point) => unproject(point, origin));
|
|
const rightEdge = removeOffsetFolds(right, points).map((point) => unproject(point, origin));
|
|
if (leftEdge.length < 2 || rightEdge.length < 2) return null;
|
|
const ring = [...leftEdge, ...rightEdge.reverse(), leftEdge[0]];
|
|
return ring.every((point) => point.every(Number.isFinite)) ? ring : null;
|
|
}
|
|
|
|
function sidewalkRing(line, innerOffset, outerOffset, side) {
|
|
const inner = offsetLine(line, innerOffset * side);
|
|
const outer = offsetLine(line, outerOffset * side);
|
|
if (!inner || !outer) return null;
|
|
const ring = [...inner, ...outer.reverse(), inner[0]];
|
|
return ring.every((point) => point.every(Number.isFinite)) ? ring : null;
|
|
}
|
|
|
|
function project(point, origin) {
|
|
const scale = 111320;
|
|
return [(point[0] - origin[0]) * scale * Math.cos((origin[1] * Math.PI) / 180), (point[1] - origin[1]) * scale];
|
|
}
|
|
function unproject(point, origin) {
|
|
const scale = 111320;
|
|
return [point[0] / (scale * Math.cos((origin[1] * Math.PI) / 180)) + origin[0], point[1] / scale + origin[1]];
|
|
}
|
|
function diagnostic(severity, subjectId, sourceIds, rule, message, coordinate) {
|
|
return {
|
|
id: `diagnostic:${rule}:${subjectId}`,
|
|
severity,
|
|
subjectId,
|
|
sourceIds,
|
|
rule,
|
|
message,
|
|
geometry: coordinate ? { type: 'Point', coordinates: coordinate } : null,
|
|
};
|
|
}
|
|
function xmlAttrs(text) {
|
|
const attrs = {};
|
|
for (const match of text.matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) attrs[match[1]] = match[2] ?? match[3];
|
|
return attrs;
|
|
}
|
|
function parseTags(body) {
|
|
const tags = {};
|
|
for (const match of body.matchAll(/<tag\b([^>]*)\/?\s*>/g)) {
|
|
const attrs = xmlAttrs(match[1]);
|
|
if (attrs.k) tags[attrs.k] = attrs.v || '';
|
|
}
|
|
return tags;
|
|
}
|
|
function positiveInteger(value) {
|
|
const number = Number(value);
|
|
return Number.isInteger(number) && number > 0 ? number : null;
|
|
}
|
|
function positiveNumber(value) {
|
|
const match = String(value ?? '').match(/^\s*(\d+(?:\.\d+)?)/);
|
|
const number = match ? Number(match[1]) : null;
|
|
return Number.isFinite(number) && number > 0 ? number : null;
|
|
}
|
|
function writeJsonAtomic(file, data) {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
const temporary = `${file}.${process.pid}.tmp`;
|
|
fs.writeFileSync(temporary, `${JSON.stringify(data, null, 2)}\n`);
|
|
fs.renameSync(temporary, file);
|
|
}
|
|
|
|
module.exports = {
|
|
OVERRIDE_SCHEMA,
|
|
compileRoadModel,
|
|
compileGeometry,
|
|
loadOverrides,
|
|
validateOverrides,
|
|
writeJsonAtomic,
|
|
};
|