Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
rubenthoms committed Nov 20, 2024
1 parent 3ddc0af commit 65faf4d
Show file tree
Hide file tree
Showing 4 changed files with 451 additions and 39 deletions.
148 changes: 148 additions & 0 deletions frontend/src/lib/utils/ForceDirectedEntityPositioning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { cloneDeep } from "lodash";

export type Entity = {
coordinates: [number, number];
anchorCoordinates: [number, number];
};

export type ForceDirectedEntityPositioningOptions = {
springRestLength?: number;
springConstant?: number;
chargeConstant?: number;
tolerance?: number;
maxIterations?: number;
};
export class ForceDirectedEntityPositioning<TEntity extends Entity> {
private _entities: TEntity[] = [];
private _adjustedEntities: TEntity[] = [];

private _options: {
[K in keyof ForceDirectedEntityPositioningOptions]-?: ForceDirectedEntityPositioningOptions[K];
} = {
chargeConstant: 50,
springConstant: 0.00001,
springRestLength: 0,
tolerance: 0.1,
maxIterations: 10000,
};

constructor(entities: TEntity[], options?: ForceDirectedEntityPositioningOptions) {
this._entities = entities;

if (options) {
this._options = {
...this._options,
...options,
};
}
}

reset(): void {
this._adjustedEntities = cloneDeep(this._entities);
}

run(): TEntity[] {
this.reset();

this.iterateUntilConvergence();

return this._adjustedEntities;
}

iterateUntilConvergence(): void {
for (let i = 0; i < this._options.maxIterations; i++) {
const totalForce = this.iterate();

if (totalForce < this._options.tolerance) {
break;
}

if (i === this._options.maxIterations - 1) {
console.warn(
"Force-directed label positioning did not converge within the maximum number of iterations."
);
}
}
}

private iterate(): number {
let aggregatedTotalForce: number = 0;

for (let i = 0; i < this._adjustedEntities.length; i++) {
let totalForce: [number, number] = [0, 0];

// First, calculate the attraction force (spring force) between the entity and its anchor
const entity = this._adjustedEntities[i];

const coordinates = entity.coordinates;
const anchorCoordinates = entity.anchorCoordinates;

const [fAx, fAy] = this.calcAttractionForce(coordinates, anchorCoordinates);
totalForce = [totalForce[0] + fAx, totalForce[1] + fAy];

// Next, calculate the repulsion force between the entity and all other entities and their anchors
for (let j = 0; j < this._adjustedEntities.length; j++) {
if (i === j) {
continue;
}

const otherEntity = this._adjustedEntities[j];

const [fRx, fRy] = this.calcRepulsionForce(
coordinates,
otherEntity.coordinates,
this._options.chargeConstant
);
totalForce = [totalForce[0] + fRx, totalForce[1] + fRy];

const [fRx2, fRy2] = this.calcRepulsionForce(
coordinates,
otherEntity.anchorCoordinates,
this._options.chargeConstant / 2
);
totalForce = [totalForce[0] + fRx2, totalForce[1] + fRy2];
}

this._adjustedEntities[i] = {
...entity,
coordinates: [coordinates[0] + totalForce[0], coordinates[1] + totalForce[1]],
};

aggregatedTotalForce += Math.sqrt(totalForce[0] ** 2 + totalForce[1] ** 2);
}

return aggregatedTotalForce;
}

private calcAttractionForce(point: [number, number], otherPoint: [number, number]): [number, number] {
const d = Math.sqrt((point[0] - otherPoint[0]) ** 2 + (point[1] - otherPoint[1]) ** 2);
const dx = Math.sqrt((point[0] - otherPoint[0]) ** 2);
const dy = Math.sqrt((point[1] - otherPoint[1]) ** 2);

let directionX = (otherPoint[0] - point[0]) / dx;
let directionY = (otherPoint[1] - point[1]) / dy;
if (Number.isNaN(directionX)) {
directionX = 0;
}
if (Number.isNaN(directionY)) {
directionY = 0;
}

const force = this._options.springConstant * (d - this._options.springRestLength);

return [force * directionX, force * directionY];
}

private calcRepulsionForce(point: [number, number], otherPoint: [number, number], beta = 10): [number, number] {
let d = Math.sqrt((point[0] - otherPoint[0]) ** 2 + (point[1] - otherPoint[1]) ** 2);
if (d === 0) {
d = 0.0001;
}
const directionX = (point[0] - otherPoint[0]) / d;
const directionY = (point[1] - otherPoint[1]) / d;

const force = beta / d ** 2;

return [force * directionX, force * directionY];
}
}
219 changes: 219 additions & 0 deletions frontend/src/modules/2DViewer/view/customDeckGlLayers/LabelLayer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { CompositeLayer, FilterContext } from "@deck.gl/core";
import { LineLayer, TextLayer } from "@deck.gl/layers";
import { ForceDirectedEntityPositioning } from "@lib/utils/ForceDirectedEntityPositioning";
import { PointsLayer } from "@webviz/subsurface-viewer/dist/layers";

type LabelData = {
coordinates: [number, number, number];
name: string;
};

type IntermediateLabelData = {
name: string;
otherNames: string[];
coordinates: [number, number];
anchorCoordinates: [number, number];
};

type ExtendedLabelData = {
name: string;
otherNames: string[];
coordinates: [number, number, number];
anchorCoordinates: [number, number, number];
};

export type LabelLayerProps = {
id: string;
data: LabelData[];
fontSize: number;
sizeMinPixels: number;
sizeMaxPixels: number;
};

type BoundingBox2D = {
topLeft: number[];
bottomRight: number[];
};

export class LabelLayer extends CompositeLayer<LabelLayerProps> {
static layerName: string = "LabelLayer";

private _labelBoundingBoxes: (BoundingBox2D | null)[] = [];
private _adjustedData: ExtendedLabelData[] = [];

estimateLabelBoundingBoxes(): void {
const viewport = this.context.viewport;
const viewportBounds = viewport.getBounds();

for (const label of this.props.data) {
const [xWorld, yWorld] = label.coordinates;

if (
xWorld < viewportBounds[0] ||
xWorld > viewportBounds[2] ||
yWorld < viewportBounds[1] ||
yWorld > viewportBounds[3]
) {
this._labelBoundingBoxes.push(null);
continue;
}

const [xScreen, yScreen] = viewport.project([xWorld, yWorld]);

const numChars = label.name.length;
const fontSize = this.props.fontSize ?? 16;
const charWidth = fontSize / 1.5;
const charHeight = fontSize;
const labelWidth = numChars * charWidth;
const labelHeight = charHeight;

const topLeftScreen: [number, number] = [xScreen - labelWidth / 2, yScreen - labelHeight / 2];
const bottomRightScreen: [number, number] = [xScreen + labelWidth / 2, yScreen + labelHeight / 2];

const topLeftWorld = viewport.unproject(topLeftScreen);
const bottomRightWorld = viewport.unproject(bottomRightScreen);

this._labelBoundingBoxes.push({
topLeft: topLeftWorld,
bottomRight: bottomRightWorld,
});
}
}

collectLabelGroups(): IntermediateLabelData[] {
const labelGroups: IntermediateLabelData[] = [];

for (const label of this.props.data) {
const [xWorld, yWorld] = label.coordinates;

const group = labelGroups.find((group) => {
const [xGroup, yGroup] = group.coordinates;

return Math.abs(xGroup - xWorld) < 0.1 && Math.abs(yGroup - yWorld) < 0.1;
});

if (group) {
group.otherNames.push(label.name);
} else {
labelGroups.push({
name: label.name,
coordinates: [xWorld, yWorld],
anchorCoordinates: [xWorld, yWorld],
otherNames: [],
});
}
}

return labelGroups;
}

reduceCollidingLabels(): ExtendedLabelData[] {
const labels = this.collectLabelGroups();
const forceDirectedEntityPositioning = new ForceDirectedEntityPositioning(labels, {
springRestLength: 25,
springConstant: 0.2,
chargeConstant: 150,
tolerance: 0.1,
maxIterations: 500,
});

const adjustedLabels = forceDirectedEntityPositioning.run();

return adjustedLabels.map((label) => ({
name: label.name,
otherNames: label.otherNames,
coordinates: [label.coordinates[0], label.coordinates[1], 0] as [number, number, number],
anchorCoordinates: [label.anchorCoordinates[0], label.anchorCoordinates[1], 0] as [number, number, number],
}));
}

updateState(): void {
this._adjustedData = this.reduceCollidingLabels();
}

filterSubLayer(context: FilterContext): boolean {
if (context.layer.id.includes("text")) {
return context.viewport.zoom > -2;
}

return true;
}

renderLayers() {
const sizeMinPixels = 14;
const sizeMaxPixels = 14;

return [
new PointsLayer(
this.getSubLayerProps({
id: "points",
pointsData: this._adjustedData.flatMap((d) => d.anchorCoordinates),
pointRadius: 3,
color: [0, 0, 0],
radiusUnits: "pixels",
sizeMinPixels: sizeMinPixels,
sizeMaxPixels: sizeMaxPixels,
})
),
new LineLayer(
this.getSubLayerProps({
id: "lines",
data: this._adjustedData,
getSourcePosition: (d: ExtendedLabelData) => d.anchorCoordinates,
getTargetPosition: (d: ExtendedLabelData) => d.coordinates,
getColor: [0, 0, 0],
getLineWidth: 1,
sizeUnits: "pixels",
sizeMinPixels: sizeMinPixels,
sizeMaxPixels: sizeMaxPixels,
})
),
new TextLayer(
this.getSubLayerProps({
id: "text",
data: this._adjustedData,
getPosition: (d: ExtendedLabelData) => d.coordinates,
getText: (d: ExtendedLabelData) =>
`${d.name} ${d.otherNames.length > 0 ? `(+${d.otherNames.length})` : ""}`,
getSize: 12,
getColor: [255, 255, 255],
outlineColor: [0, 0, 0],
outlineWidth: 2,
getAngle: 0,
getPixelOffset: [0, 0],
fontWeight: 800,
getTextAnchor: "middle",
getAlignmentBaseline: "center",
fontSettings: {
fontSize: 16,
},
sizeScale: 1,
sizeUnits: "meters",
sizeMinPixels: sizeMinPixels,
sizeMaxPixels: sizeMaxPixels,
getBackgroundColor: [0, 0, 0, 255],
background: true,
})
),
/*
new PolygonLayer(
this.getSubLayerProps({
id: "bounding-boxes",
data: this._labelBoundingBoxes.filter((d) => d !== null) as BoundingBox2D[],
getPolygon: (d: BoundingBox2D) => [
[d.topLeft[0], d.topLeft[1]],
[d.bottomRight[0], d.topLeft[1]],
[d.bottomRight[0], d.bottomRight[1]],
[d.topLeft[0], d.bottomRight[1]],
],
getLineColor: [255, 255, 255],
getFillColor: [255, 255, 255, 0],
getLineWidth: 5,
stroked: true,
sizeUnits: "pixels",
})
),
*/
];
}
}
Loading

0 comments on commit 65faf4d

Please sign in to comment.