diff --git a/package-lock.json b/package-lock.json index 285a046..dbd5f85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@tauri-apps/plugin-fs": "^2.0.0-beta.0", "@turf/area": "^6.5.0", "@turf/boolean-contains": "^6.5.0", + "@turf/boolean-point-in-polygon": "^6.5.0", "@turf/buffer": "^6.5.0", "@turf/center-of-mass": "^6.5.0", "@turf/circle": "^6.5.0", diff --git a/package.json b/package.json index 9760c9d..4bf5c30 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "@tauri-apps/plugin-fs": "^2.0.0-beta.0", "@turf/area": "^6.5.0", "@turf/boolean-contains": "^6.5.0", + "@turf/boolean-point-in-polygon": "^6.5.0", "@turf/buffer": "^6.5.0", "@turf/center-of-mass": "^6.5.0", "@turf/circle": "^6.5.0", diff --git a/src/renderer/src/lib/map/data/processBorders.ts b/src/renderer/src/lib/map/data/processBorders.ts index 8f41647..bdd99bb 100644 --- a/src/renderer/src/lib/map/data/processBorders.ts +++ b/src/renderer/src/lib/map/data/processBorders.ts @@ -1,11 +1,15 @@ +import booleanPointInPolygon from '@turf/boolean-point-in-polygon'; import buffer from '@turf/buffer'; import difference from '@turf/difference'; import explode from '@turf/explode'; import * as helpers from '@turf/helpers'; +import { coordAll } from '@turf/meta'; +import union from '@turf/union'; import type { GameState, Sector } from '../../GameState'; import type { MapSettings } from '../../mapSettings'; import { getOrDefault, isDefined, parseNumberEntry } from '../../utils'; import type processCircularGalaxyBorders from './processCircularGalaxyBorder'; +import type { BorderCircle } from './processCircularGalaxyBorder'; import type processHyperRelays from './processHyperRelays'; import type processSystemOwnership from './processSystemOwnership'; import type processTerraIncognita from './processTerraIncognita'; @@ -17,8 +21,12 @@ import { getAllPositionArrays, getCountryColors, getFrontierSectorPseudoId, + getPolygons, + getSharedDistancePercent, getUnionLeaderId, + makeBorderCircleGeojson, multiPolygonToPath, + pointToGeoJSON, positionToString, segmentToPath, type PolygonalFeature, @@ -31,15 +39,18 @@ export default function processBorders( countryToGeojson: Record, sectorToGeojson: Record, unionLeaderToUnionMembers: ReturnType['unionLeaderToUnionMembers'], + unionLeaderToSystemIds: Record>, countryToOwnedSystemIds: Record>, systemIdToUnionLeader: ReturnType['systemIdToUnionLeader'], relayMegastructures: ReturnType, knownCountries: ReturnType['knownCountries'], + galaxyBorderCircles: BorderCircle[], galaxyBorderCirclesGeoJSON: ReturnType< typeof processCircularGalaxyBorders >['galaxyBorderCirclesGeoJSON'], getSystemCoordinates: (id: number, options?: { invertX?: boolean }) => [number, number], ) { + const unassignedFragments: [number, helpers.Feature][] = []; const borders = Object.entries(unionLeaderToGeojson) .map(parseNumberEntry) .map(([countryId, outerBorderGeoJSON]) => { @@ -205,35 +216,62 @@ export default function processBorders( }); } - const boundedOuterBorderGeoJSON = applyGalaxyBoundary( + let boundedOuterBorderGeoJSON = applyGalaxyBoundary( outerBorderGeoJSON, galaxyBorderCirclesGeoJSON, ); - let smoothedOuterBorderGeoJSON = boundedOuterBorderGeoJSON; - if (settings.borderStroke.smoothing && boundedOuterBorderGeoJSON != null) { - smoothedOuterBorderGeoJSON = smoothGeojson(boundedOuterBorderGeoJSON, 2); + + if (galaxyBorderCirclesGeoJSON) { + const fragments: helpers.Feature[] = []; + for (const polygon of getPolygons(boundedOuterBorderGeoJSON)) { + const systems = new Set( + Array.from(unionLeaderToSystemIds[countryId] ?? []).filter((systemId) => { + const coordinate = getSystemCoordinates(systemId); + const point = helpers.point(pointToGeoJSON(coordinate)); + return booleanPointInPolygon(point, polygon); + }), + ); + if (systems.size === 0) { + fragments.push(polygon); + unassignedFragments.push([countryId, polygon]); + } else { + const circles = galaxyBorderCircles.filter((c) => { + if (c.type !== 'outer-padded' && c.type !== 'outlier') return false; + if (systems.size < c.systems.size) { + return Array.from(systems).some((id) => c.systems.has(id)); + } else { + return Array.from(c.systems).some((id) => systems.has(id)); + } + }); + const outerCircles = circles.filter((c) => c.type === 'outer-padded'); + if (outerCircles.length === 1 && !outerCircles[0]?.isMainCluster) { + // all stars in this polygon belong to a non-main cluster + // try to find fragments outside of it's cluster's bounds + const bounds = circles.reduce((acc, cur) => { + const geojson = makeBorderCircleGeojson(gameState, getSystemCoordinates, cur); + if (acc == null) return geojson; + if (geojson == null) return acc; + return union(acc, geojson); + }, null); + const outOfBounds = bounds == null ? null : difference(polygon, bounds); + fragments.push(...getPolygons(outOfBounds)); + unassignedFragments.push( + ...getPolygons(outOfBounds).map<(typeof unassignedFragments)[number]>((geojson) => [ + countryId, + geojson, + ]), + ); + } + } + } + for (const fragment of fragments) { + boundedOuterBorderGeoJSON = + boundedOuterBorderGeoJSON == null + ? null + : difference(boundedOuterBorderGeoJSON, fragment); + } } - const smoothedInnerBorderGeoJSON = - smoothedOuterBorderGeoJSON == null - ? null - : (buffer(smoothedOuterBorderGeoJSON, -settings.borderStroke.width / SCALE, { - units: 'degrees', - }) as ReturnType | null); - const outerPath = - smoothedOuterBorderGeoJSON == null - ? '' - : multiPolygonToPath(smoothedOuterBorderGeoJSON, settings.borderStroke.smoothing); - const innerPath = - smoothedInnerBorderGeoJSON == null - ? '' - : multiPolygonToPath(smoothedInnerBorderGeoJSON, settings.borderStroke.smoothing); - const borderOnlyGeoJSON = - smoothedInnerBorderGeoJSON == null || smoothedOuterBorderGeoJSON == null - ? smoothedOuterBorderGeoJSON - : difference(smoothedOuterBorderGeoJSON, smoothedInnerBorderGeoJSON); - const borderPath = borderOnlyGeoJSON - ? multiPolygonToPath(borderOnlyGeoJSON, settings.borderStroke.smoothing) - : ''; + const { hyperlanesPath, relayHyperlanesPath } = createHyperlanePaths( gameState, settings, @@ -242,13 +280,15 @@ export default function processBorders( countryId, getSystemCoordinates, ); + return { countryId, primaryColor, secondaryColor, - outerPath, - innerPath, - borderPath, + outerPath: '', // we need to wait for these, because fragments might need to be assigned + innerPath: '', // we need to wait for these, because fragments might need to be assigned + borderPath: '', // we need to wait for these, because fragments might need to be assigned + geojson: boundedOuterBorderGeoJSON, hyperlanesPath, relayHyperlanesPath, sectorBorders: nonEmptySectorSegments.map((segment) => ({ @@ -264,5 +304,69 @@ export default function processBorders( }; }) .filter(isDefined); + + const unionLeaderToPositionStrings: Record> = Object.fromEntries( + borders.map((border) => [ + border.countryId, + new Set( + border.geojson == null + ? [] + : (coordAll(border.geojson) as [number, number][]).map(positionToString), + ), + ]), + ); + + for (const [originalCountryId, fragment] of unassignedFragments) { + let unionLeaderId: number | undefined; + let unionLeaderSharedDistancePercent = Number.EPSILON; + Object.entries(unionLeaderToGeojson) + .map(parseNumberEntry) + .filter(([id]) => id !== originalCountryId) + .forEach(([id]) => { + const sharedDistancePercent = getSharedDistancePercent( + fragment, + getOrDefault(unionLeaderToPositionStrings, id, new Set()), + ); + if (sharedDistancePercent >= unionLeaderSharedDistancePercent) { + unionLeaderId = id; + unionLeaderSharedDistancePercent = sharedDistancePercent; + } + }); + const border = + unionLeaderId == null ? null : borders.find((b) => b.countryId === unionLeaderId); + const geojson = border == null ? null : border.geojson; + if (border && geojson) { + border.geojson = union(geojson, fragment); + } + } + + for (const border of borders) { + const boundedOuterBorderGeoJSON = border.geojson; + let smoothedOuterBorderGeoJSON = boundedOuterBorderGeoJSON; + if (settings.borderStroke.smoothing && boundedOuterBorderGeoJSON != null) { + smoothedOuterBorderGeoJSON = smoothGeojson(boundedOuterBorderGeoJSON, 2); + } + const smoothedInnerBorderGeoJSON = + smoothedOuterBorderGeoJSON == null + ? null + : (buffer(smoothedOuterBorderGeoJSON, -settings.borderStroke.width / SCALE, { + units: 'degrees', + }) as ReturnType | null); + border.outerPath = + smoothedOuterBorderGeoJSON == null + ? '' + : multiPolygonToPath(smoothedOuterBorderGeoJSON, settings.borderStroke.smoothing); + border.innerPath = + smoothedInnerBorderGeoJSON == null + ? '' + : multiPolygonToPath(smoothedInnerBorderGeoJSON, settings.borderStroke.smoothing); + const borderOnlyGeoJSON = + smoothedInnerBorderGeoJSON == null || smoothedOuterBorderGeoJSON == null + ? smoothedOuterBorderGeoJSON + : difference(smoothedOuterBorderGeoJSON, smoothedInnerBorderGeoJSON); + border.borderPath = borderOnlyGeoJSON + ? multiPolygonToPath(borderOnlyGeoJSON, settings.borderStroke.smoothing) + : ''; + } return borders; } diff --git a/src/renderer/src/lib/map/data/processCircularGalaxyBorder.ts b/src/renderer/src/lib/map/data/processCircularGalaxyBorder.ts index ccef166..18a5fb3 100644 --- a/src/renderer/src/lib/map/data/processCircularGalaxyBorder.ts +++ b/src/renderer/src/lib/map/data/processCircularGalaxyBorder.ts @@ -1,6 +1,4 @@ -import buffer from '@turf/buffer'; import centerOfMass from '@turf/center-of-mass'; -import turfCircle from '@turf/circle'; import convex from '@turf/convex'; import difference from '@turf/difference'; import * as helpers from '@turf/helpers'; @@ -9,13 +7,18 @@ import { interpolateBasis } from 'd3-interpolate'; import type { GameState } from '../../GameState'; import type { MapSettings } from '../../mapSettings'; import type { NonEmptyArray } from '../../utils'; -import { SCALE, pointFromGeoJSON, pointToGeoJSON, type PolygonalFeature } from './utils'; +import { + SCALE, + makeBorderCircleGeojson, + pointFromGeoJSON, + pointToGeoJSON, + type PolygonalFeature, +} from './utils'; const CIRCLE_OUTER_PADDING = 15; const CIRCLE_INNER_PADDING = 10; const OUTLIER_DISTANCE = 30; const OUTLIER_RADIUS = 15; -const OUTLIER_HYPERLANE_PADDING = 7.5; const STARBURST_NUM_SLICES = 12; const STARBURST_LINES_PER_SLICE = 50; const STARBURST_SLICE_ANGLE = (Math.PI * 2) / STARBURST_NUM_SLICES; @@ -26,6 +29,7 @@ export interface BorderCircle { cy: number; r: number; type: 'inner' | 'outer' | 'inner-padded' | 'outer-padded' | 'outlier'; + isMainCluster: boolean; systems: Set; } export default function processCircularGalaxyBorders( @@ -148,10 +152,7 @@ export default function processCircularGalaxyBorders( const galaxyBorderCircles = clusters .map((cluster) => { - const isStarburstCluster = - cluster === mainCluster && - gameState.galaxy.shape === 'starburst' && - settings.circularGalaxyBorders; + const isMainCluster = cluster === mainCluster; let cx = (cluster.bBox.xMin + cluster.bBox.xMax) / 2; let cy = (cluster.bBox.yMin + cluster.bBox.yMax) / 2; if (cluster === mainCluster) { @@ -180,6 +181,7 @@ export default function processCircularGalaxyBorders( r: maxR, type: 'outer', systems: cluster.systems, + isMainCluster, }, { cx, @@ -187,10 +189,18 @@ export default function processCircularGalaxyBorders( r: maxR + CIRCLE_OUTER_PADDING, type: 'outer-padded', systems: cluster.systems, + isMainCluster, }, ]; if (minR > 0) { - clusterCircles.push({ cx, cy, r: minR, type: 'inner', systems: cluster.systems }); + clusterCircles.push({ + cx, + cy, + r: minR, + type: 'inner', + systems: cluster.systems, + isMainCluster, + }); } if (minR > CIRCLE_OUTER_PADDING) { clusterCircles.push({ @@ -199,11 +209,9 @@ export default function processCircularGalaxyBorders( r: minR - CIRCLE_INNER_PADDING, type: 'inner-padded', systems: cluster.systems, + isMainCluster, }); } - // inner/outer borders are specially handled for starburst - // we only want the following outlier circles for starburst - if (isStarburstCluster) clusterCircles.length = 0; clusterCircles.push( ...Array.from(cluster.outliers).map((outlierId) => ({ cx: getSystemCoordinates(outlierId)[0], @@ -211,6 +219,7 @@ export default function processCircularGalaxyBorders( r: OUTLIER_RADIUS, type: 'outlier' as const, systems: new Set([outlierId]), + isMainCluster, })), ); return clusterCircles; @@ -227,34 +236,17 @@ export default function processCircularGalaxyBorders( } let galaxyBorderCirclesGeoJSON: null | PolygonalFeature = starburstGeoJSON; - for (const circle of galaxyBorderCircles) { - const polygon = turfCircle(pointToGeoJSON([circle.cx, circle.cy]), circle.r / SCALE, { - units: 'degrees', - steps: Math.ceil(circle.r), - }); + for (const circle of galaxyBorderCircles.filter( + (circle) => starburstGeoJSON == null || !circle.isMainCluster || circle.type === 'outlier', + )) { + const polygon = makeBorderCircleGeojson(gameState, getSystemCoordinates, circle); + if (polygon == null) continue; if (circle.type === 'outer-padded' || circle.type === 'outlier') { if (galaxyBorderCirclesGeoJSON == null) { galaxyBorderCirclesGeoJSON = polygon; } else { galaxyBorderCirclesGeoJSON = union(galaxyBorderCirclesGeoJSON, polygon); } - if (circle.type === 'outlier' && galaxyBorderCirclesGeoJSON != null) { - const multiLineString = helpers.multiLineString( - Array.from(circle.systems).flatMap((systemId) => { - const system = gameState.galactic_object[systemId]; - if (system == null) return []; - return system.hyperlane.map(({ to }) => [ - pointToGeoJSON(getSystemCoordinates(systemId)), - pointToGeoJSON(getSystemCoordinates(to)), - ]); - }), - ); - const hyperlaneBuffer = buffer(multiLineString, OUTLIER_HYPERLANE_PADDING / SCALE, { - units: 'degrees', - steps: 1, - }); - galaxyBorderCirclesGeoJSON = union(galaxyBorderCirclesGeoJSON, hyperlaneBuffer); - } } else if (circle.type === 'inner-padded') { if (galaxyBorderCirclesGeoJSON != null) { galaxyBorderCirclesGeoJSON = difference(galaxyBorderCirclesGeoJSON, polygon); diff --git a/src/renderer/src/lib/map/data/processLabels.ts b/src/renderer/src/lib/map/data/processLabels.ts index 6b0e23d..5326199 100644 --- a/src/renderer/src/lib/map/data/processLabels.ts +++ b/src/renderer/src/lib/map/data/processLabels.ts @@ -3,32 +3,23 @@ import * as helpers from '@turf/helpers'; import polylabel from 'polylabel'; import type { GameState } from '../../GameState'; import type { MapSettings } from '../../mapSettings'; +import type processBorders from './processBorders'; import type processNames from './processNames'; import type processSystemOwnership from './processSystemOwnership'; import type processTerraIncognita from './processTerraIncognita'; -import { - SCALE, - applyGalaxyBoundary, - getPolygons, - inverseX, - isUnionLeader, - pointFromGeoJSON, - type PolygonalFeature, -} from './utils'; +import { SCALE, getPolygons, inverseX, isUnionLeader, pointFromGeoJSON } from './utils'; export default function processLabels( gameState: GameState, settings: MapSettings, - countryToGeojson: Record, + borders: ReturnType, countryNames: Awaited>, - galaxyBorderCirclesGeoJSON: PolygonalFeature | null, knownCountries: ReturnType['knownCountries'], ownedSystemPoints: ReturnType['ownedSystemPoints'], ) { - const labels = Object.entries(countryToGeojson).map(([countryId, unboundedCountryGeojson]) => { - const countryGeojson = applyGalaxyBoundary(unboundedCountryGeojson, galaxyBorderCirclesGeoJSON); + const labels = borders.map(({ countryId, geojson }) => { const name = countryNames[countryId] ?? ''; - const country = gameState.country[parseInt(countryId)]; + const country = gameState.country[countryId]; const textAspectRatio = name && settings.countryNames ? getTextAspectRatio(name, settings.countryNamesFont) : 0; @@ -42,9 +33,10 @@ export default function processLabels( searchAspectRatio = textAspectRatio; } const labelPoints = - searchAspectRatio && countryGeojson - ? getPolygons(countryGeojson) - .map((p) => { + searchAspectRatio && geojson + ? getPolygons(geojson) + .map((feature) => { + const p = feature.geometry; if (settings.labelsAvoidHoles === 'all') return p; if (settings.labelsAvoidHoles === 'none') return helpers.polygon([p.coordinates[0] ?? []]).geometry; @@ -127,8 +119,8 @@ export default function processLabels( labelPoints, name, emblemKey, - isUnionLeader: isUnionLeader(parseInt(countryId), gameState, settings), - isKnown: knownCountries.has(parseInt(countryId)), + isUnionLeader: isUnionLeader(countryId, gameState, settings), + isKnown: knownCountries.has(countryId), }; }); return labels; diff --git a/src/renderer/src/lib/map/data/processMapData.ts b/src/renderer/src/lib/map/data/processMapData.ts index f23adee..cd8e272 100644 --- a/src/renderer/src/lib/map/data/processMapData.ts +++ b/src/renderer/src/lib/map/data/processMapData.ts @@ -101,18 +101,6 @@ export default async function processMapData(gameState: GameState, settings: Map null, getSystemCoordinates, ); - const countryNames = await countryNamesPromise; - const labels = timeIt( - 'labels', - processLabels, - gameState, - settings, - countryToGeojson, - countryNames, - galaxyBorderCirclesGeoJSON, - knownCountries, - ownedSystemPoints, - ); const borders = timeIt( 'borders', processBorders, @@ -122,13 +110,26 @@ export default async function processMapData(gameState: GameState, settings: Map countryToGeojson, sectorToGeojson, unionLeaderToUnionMembers, + unionLeaderToSystemIds, countryToSystemIds, systemIdToUnionLeader, relayMegastructures, knownCountries, + galaxyBorderCircles, galaxyBorderCirclesGeoJSON, getSystemCoordinates, ); + const countryNames = await countryNamesPromise; + const labels = timeIt( + 'labels', + processLabels, + gameState, + settings, + borders, + countryNames, + knownCountries, + ownedSystemPoints, + ); const systems = timeIt( 'systems', processSystems, diff --git a/src/renderer/src/lib/map/data/processPolygons.ts b/src/renderer/src/lib/map/data/processPolygons.ts index d285687..9854e55 100644 --- a/src/renderer/src/lib/map/data/processPolygons.ts +++ b/src/renderer/src/lib/map/data/processPolygons.ts @@ -1,6 +1,6 @@ import turfArea from '@turf/area'; import * as helpers from '@turf/helpers'; -import { coordAll, segmentEach } from '@turf/meta'; +import { coordAll } from '@turf/meta'; import { Delaunay, Voronoi } from 'd3-delaunay'; import * as topojsonClient from 'topojson-client'; import * as topojsonServer from 'topojson-server'; @@ -13,6 +13,8 @@ import { SCALE, closeRings, getAllPositionArrays, + getPolygons, + getSharedDistancePercent, pointToGeoJSON, positionToString, type PolygonalFeature, @@ -93,7 +95,10 @@ export default function processPolygons( ); let unionLeaderId: number | undefined; - let unionLeaderSharedDistancePercent = settings.claimVoidBorderThreshold; + let unionLeaderSharedDistancePercent = Math.max( + settings.claimVoidBorderThreshold, + Number.EPSILON, + ); Object.entries(unionLeaderToGeojson) .map(parseNumberEntry) .forEach(([id]) => { @@ -173,8 +178,10 @@ function mergeSystemPolygons( function topologicallyMergeDelaunayPolygons( systemPolygons: (Delaunay.Polygon | null | undefined)[], ) { - const nonNullishPolygons = systemPolygons.filter(isDefined); - if (!nonNullishPolygons.length) return null; + const nonNullishPolygons = systemPolygons + .filter(isDefined) + .filter((points) => points.length >= 4); + if (nonNullishPolygons.length === 0) return null; const geojsonPolygons = nonNullishPolygons.map((points) => helpers.polygon([points.map(pointToGeoJSON)]), ); @@ -197,37 +204,11 @@ function topologicallyMergePolygons(geojsonPolygons: PolygonalFeature[]) { } function getVoidPolygons(topology: Topology) { + if (topology.objects[-1] == null) return []; const voidGeojson = topojsonClient.feature(topology, '-1') as unknown as PolygonalFeature | null; - if (voidGeojson?.geometry.type === 'Polygon') { - return [voidGeojson as helpers.Feature]; - } else if (voidGeojson?.geometry.type === 'MultiPolygon') { - return voidGeojson.geometry.coordinates.map((coordinates) => helpers.polygon(coordinates)); - } else { - return []; - } + return getPolygons(voidGeojson); } -const getSharedDistancePercent = ( - polygon: helpers.Feature, - sharedPositionStrings: Set, -) => { - let sharedDistance = 0; - let totalDistance = 0; - segmentEach(polygon, (segment) => { - const from = segment?.geometry.coordinates[0] ?? [0, 0]; - const to = segment?.geometry.coordinates[1] ?? [0, 0]; - const segmentDistance = Math.hypot(from[0] - to[0], from[1] - to[1]); - totalDistance += segmentDistance; - if ( - sharedPositionStrings.has(positionToString(from)) && - sharedPositionStrings.has(positionToString(to)) - ) { - sharedDistance += segmentDistance; - } - }); - return sharedDistance / totalDistance; -}; - function addPolygonToGeojsonMapping( polygon: helpers.Feature, mapping: Record, diff --git a/src/renderer/src/lib/map/data/utils.ts b/src/renderer/src/lib/map/data/utils.ts index a723649..202265a 100644 --- a/src/renderer/src/lib/map/data/utils.ts +++ b/src/renderer/src/lib/map/data/utils.ts @@ -1,10 +1,15 @@ +import buffer from '@turf/buffer'; +import turfCircle from '@turf/circle'; import * as helpers from '@turf/helpers'; import intersect from '@turf/intersect'; +import { segmentEach } from '@turf/meta'; +import union from '@turf/union'; import type { GameState } from '../../GameState'; import type { MapSettings } from '../../mapSettings'; // @ts-expect-error pathRound is missing from d3-path type defs import { pathRound } from 'd3-path'; import { curveBasis, curveBasisClosed, curveLinear, curveLinearClosed } from 'd3-shape'; +import type { BorderCircle } from './processCircularGalaxyBorder'; export type PolygonalGeometry = helpers.Polygon | helpers.MultiPolygon; export type PolygonalFeature = helpers.Feature; @@ -119,14 +124,17 @@ export function segmentToPath(segment: helpers.Position[], smooth: boolean): str } export function getPolygons( - geojson: PolygonalFeatureCollection | PolygonalFeature, -): helpers.Polygon[] { + geojson: PolygonalFeatureCollection | PolygonalFeature | null, +): helpers.Feature[] { + if (geojson == null) return []; const features = geojson.type === 'FeatureCollection' ? geojson.features : [geojson]; return features.flatMap((feature) => { - if (feature.geometry.type === 'Polygon') { - return [feature.geometry]; + if (!['Polygon', 'MultiPolygon'].includes(feature.geometry.type)) { + return []; + } else if (feature.geometry.type === 'Polygon') { + return [feature as helpers.Feature]; } else { - return feature.geometry.coordinates.map((coords) => helpers.polygon(coords).geometry); + return feature.geometry.coordinates.map((coords) => helpers.polygon(coords)); } }); } @@ -274,3 +282,60 @@ export function applyGalaxyBoundary( } return geojson; } + +const OUTLIER_HYPERLANE_PADDING = 7.5; +export function makeBorderCircleGeojson( + gameState: GameState, + getSystemCoordinates: (id: number) => [number, number], + circle: BorderCircle, +) { + let geojson: PolygonalFeature | null = turfCircle( + pointToGeoJSON([circle.cx, circle.cy]), + circle.r / SCALE, + { + units: 'degrees', + steps: Math.ceil(circle.r), + }, + ); + + if (circle.type === 'outlier') { + const multiLineString = helpers.multiLineString( + Array.from(circle.systems).flatMap((systemId) => { + const system = gameState.galactic_object[systemId]; + if (system == null) return []; + return system.hyperlane.map(({ to }) => [ + pointToGeoJSON(getSystemCoordinates(systemId)), + pointToGeoJSON(getSystemCoordinates(to)), + ]); + }), + ); + const hyperlaneBuffer = buffer(multiLineString, OUTLIER_HYPERLANE_PADDING / SCALE, { + units: 'degrees', + steps: 1, + }); + geojson = union(geojson, hyperlaneBuffer); + } + + return geojson; +} + +export function getSharedDistancePercent( + polygon: helpers.Feature, + sharedPositionStrings: Set, +) { + let sharedDistance = 0; + let totalDistance = 0; + segmentEach(polygon, (segment) => { + const from = segment?.geometry.coordinates[0] ?? [0, 0]; + const to = segment?.geometry.coordinates[1] ?? [0, 0]; + const segmentDistance = Math.hypot(from[0] - to[0], from[1] - to[1]); + totalDistance += segmentDistance; + if ( + sharedPositionStrings.has(positionToString(from)) && + sharedPositionStrings.has(positionToString(to)) + ) { + sharedDistance += segmentDistance; + } + }); + return sharedDistance / totalDistance; +}