Skip to content

Commit

Permalink
feat(map,data): detect and reassign country border fragments created …
Browse files Browse the repository at this point in the history
…by circular galaxy borders
  • Loading branch information
MichaelMakesGames committed Feb 22, 2024
1 parent 5a9f43e commit 9ca7490
Show file tree
Hide file tree
Showing 8 changed files with 267 additions and 130 deletions.
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
160 changes: 132 additions & 28 deletions src/renderer/src/lib/map/data/processBorders.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -17,8 +21,12 @@ import {
getAllPositionArrays,
getCountryColors,
getFrontierSectorPseudoId,
getPolygons,
getSharedDistancePercent,
getUnionLeaderId,
makeBorderCircleGeojson,
multiPolygonToPath,
pointToGeoJSON,
positionToString,
segmentToPath,
type PolygonalFeature,
Expand All @@ -31,15 +39,18 @@ export default function processBorders(
countryToGeojson: Record<number, PolygonalFeature>,
sectorToGeojson: Record<number, PolygonalFeature>,
unionLeaderToUnionMembers: ReturnType<typeof processSystemOwnership>['unionLeaderToUnionMembers'],
unionLeaderToSystemIds: Record<number, Set<number>>,
countryToOwnedSystemIds: Record<number, Set<number>>,
systemIdToUnionLeader: ReturnType<typeof processSystemOwnership>['systemIdToUnionLeader'],
relayMegastructures: ReturnType<typeof processHyperRelays>,
knownCountries: ReturnType<typeof processTerraIncognita>['knownCountries'],
galaxyBorderCircles: BorderCircle[],
galaxyBorderCirclesGeoJSON: ReturnType<
typeof processCircularGalaxyBorders
>['galaxyBorderCirclesGeoJSON'],
getSystemCoordinates: (id: number, options?: { invertX?: boolean }) => [number, number],
) {
const unassignedFragments: [number, helpers.Feature<helpers.Polygon>][] = [];
const borders = Object.entries(unionLeaderToGeojson)
.map(parseNumberEntry)
.map(([countryId, outerBorderGeoJSON]) => {
Expand Down Expand Up @@ -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<helpers.Polygon>[] = [];
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<PolygonalFeature | null>((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<typeof buffer> | 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,
Expand All @@ -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) => ({
Expand All @@ -264,5 +304,69 @@ export default function processBorders(
};
})
.filter(isDefined);

const unionLeaderToPositionStrings: Record<number, Set<string>> = 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<typeof buffer> | 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;
}
60 changes: 26 additions & 34 deletions src/renderer/src/lib/map/data/processCircularGalaxyBorder.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -26,6 +29,7 @@ export interface BorderCircle {
cy: number;
r: number;
type: 'inner' | 'outer' | 'inner-padded' | 'outer-padded' | 'outlier';
isMainCluster: boolean;
systems: Set<number>;
}
export default function processCircularGalaxyBorders(
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -180,17 +181,26 @@ export default function processCircularGalaxyBorders(
r: maxR,
type: 'outer',
systems: cluster.systems,
isMainCluster,
},
{
cx,
cy,
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({
Expand All @@ -199,18 +209,17 @@ 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],
cy: getSystemCoordinates(outlierId)[1],
r: OUTLIER_RADIUS,
type: 'outlier' as const,
systems: new Set([outlierId]),
isMainCluster,
})),
);
return clusterCircles;
Expand All @@ -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);
Expand Down
Loading

0 comments on commit 9ca7490

Please sign in to comment.