Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
rubenthoms committed Nov 21, 2024
1 parent 65faf4d commit 68946a5
Show file tree
Hide file tree
Showing 3 changed files with 197 additions and 40 deletions.
57 changes: 34 additions & 23 deletions frontend/src/lib/utils/ForceDirectedEntityPositioning.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { cloneDeep } from "lodash";

export type Entity = {
export interface Entity {
coordinates: [number, number];
anchorCoordinates: [number, number];
};
chargeMagnitude?: number; // The charge magnitude of the entity. The higher the charge, the higher the repulsion. If not set, it is assumed to be 1.
}

export type ForceDirectedEntityPositioningOptions = {
springRestLength?: number;
Expand Down Expand Up @@ -80,7 +81,7 @@ export class ForceDirectedEntityPositioning<TEntity extends Entity> {
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
// Next, calculate the repulsion forces between the entity and all other entities and their anchors
for (let j = 0; j < this._adjustedEntities.length; j++) {
if (i === j) {
continue;
Expand All @@ -91,7 +92,7 @@ export class ForceDirectedEntityPositioning<TEntity extends Entity> {
const [fRx, fRy] = this.calcRepulsionForce(
coordinates,
otherEntity.coordinates,
this._options.chargeConstant
this._options.chargeConstant * (entity.chargeMagnitude ?? 1) * (otherEntity.chargeMagnitude ?? 1)
);
totalForce = [totalForce[0] + fRx, totalForce[1] + fRy];

Expand All @@ -115,34 +116,44 @@ export class ForceDirectedEntityPositioning<TEntity extends Entity> {
}

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;
let dx = otherPoint[0] - point[0];
let dy = otherPoint[1] - point[1];

if (dx === 0 && dy === 0) {
// If the points are at the same location, we add a small random offset to avoid division by zero.
dx = 0.01;
dy = 0.01;
}

const d = Math.sqrt(dx ** 2 + dy ** 2);

// Hooke's law: F = k * x
const force = this._options.springConstant * (d - this._options.springRestLength);

return [force * directionX, force * directionY];
// The force vector is co-linear to the spring given by the line between the two points.
// Hence, we can use the similarity theorems for triangles to calculate the force components.
// Moreover, we get the directions of the forces by the difference between the two points.
return [(force * dx) / d, (force * dy) / d];
}

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;
private calcRepulsionForce(point: [number, number], otherPoint: [number, number], beta: number): [number, number] {
let dx = point[0] - otherPoint[0];
let dy = point[1] - otherPoint[1];

if (dx === 0 && dy === 0) {
// If the points are at the same location, we add a small random offset to avoid division by zero.
dx = 0.01;
dy = 0.01;
}
const directionX = (point[0] - otherPoint[0]) / d;
const directionY = (point[1] - otherPoint[1]) / d;

const d = Math.sqrt(dx ** 2 + dy ** 2);

// Coulomb's law: F = (|Q * q|) / (4 * pi * eps0) * 1 / d^2 = beta / d^2.
const force = beta / d ** 2;

return [force * directionX, force * directionY];
// The force vector is co-linear to the line between the two points.
// Hence, we can use the similarity theorems for triangles to calculate the force components.
// Moreover, we get the directions of the forces by the difference between the two points.
return [(force * dx) / d, (force * dy) / d];
}
}
55 changes: 55 additions & 0 deletions frontend/src/lib/utils/ProximityGrouping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
export interface Entity {
coordinates: [number, number];
}

export type EntityGroup<TEntity extends Entity> = Entity &
TEntity & {
entities?: TEntity[];
};

export class ProximityGrouping<TEntity extends Entity> {
private _entities: TEntity[] = [];

constructor(entities: TEntity[]) {
this._entities = entities;
}

private isWithinDistance(entity1: Entity, entity2: Entity, distance: number): boolean {
const [x1, y1] = entity1.coordinates;
const [x2, y2] = entity2.coordinates;

return Math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2) <= distance;
}

groupEntities(withInDistance: number): EntityGroup<TEntity>[] {
const groups: EntityGroup<TEntity>[] = [];

for (let i = 0; i < this._entities.length; i++) {
const entity = this._entities[i];
let group: EntityGroup<TEntity> | undefined = groups.find((group) =>
this.isWithinDistance(group, entity, withInDistance)
);

if (!group) {
group = {
...entity,
coordinates: entity.coordinates,
entities: [],
};

groups.push(group);
} else {
group.coordinates = [
(group.coordinates[0] * (group.entities?.length ?? 1) + entity.coordinates[0]) /
((group.entities?.length ?? 1) + 1),
(group.coordinates[1] * (group.entities?.length ?? 1) + entity.coordinates[1]) /
((group.entities?.length ?? 1) + 1),
];
}

group.entities?.push(entity);
}

return groups;
}
}
125 changes: 108 additions & 17 deletions frontend/src/modules/2DViewer/view/customDeckGlLayers/LabelLayer.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
import { CompositeLayer, FilterContext } from "@deck.gl/core";
import { CompositeLayer, FilterContext, GetPickingInfoParams, Layer, PickingInfo } from "@deck.gl/core";
import { LineLayer, TextLayer } from "@deck.gl/layers";
import { ForceDirectedEntityPositioning } from "@lib/utils/ForceDirectedEntityPositioning";
import { Entity, ForceDirectedEntityPositioning } from "@lib/utils/ForceDirectedEntityPositioning";
import { EntityGroup, ProximityGrouping } from "@lib/utils/ProximityGrouping";
import { PointsLayer } from "@webviz/subsurface-viewer/dist/layers";

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

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

type ExtendedLabelData = {
name: string;
Expand All @@ -35,11 +34,16 @@ type BoundingBox2D = {
bottomRight: number[];
};

export type LabelPickingInfo = PickingInfo & {
additionalText?: string;
};

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

private _labelBoundingBoxes: (BoundingBox2D | null)[] = [];
private _adjustedData: ExtendedLabelData[] = [];
private _labelGroups: Map<number, EntityGroup<IntermediateLabelData>[]> = new Map();

estimateLabelBoundingBoxes(): void {
const viewport = this.context.viewport;
Expand Down Expand Up @@ -100,19 +104,19 @@ export class LabelLayer extends CompositeLayer<LabelLayerProps> {
coordinates: [xWorld, yWorld],
anchorCoordinates: [xWorld, yWorld],
otherNames: [],
chargeMagnitude: label.name.length,
});
}
}

return labelGroups;
}

reduceCollidingLabels(): ExtendedLabelData[] {
const labels = this.collectLabelGroups();
reduceCollidingLabels(labels: IntermediateLabelData[]): ExtendedLabelData[] {
const forceDirectedEntityPositioning = new ForceDirectedEntityPositioning(labels, {
springRestLength: 25,
springRestLength: 10,
springConstant: 0.2,
chargeConstant: 150,
chargeConstant: 3,
tolerance: 0.1,
maxIterations: 500,
});
Expand All @@ -128,21 +132,101 @@ export class LabelLayer extends CompositeLayer<LabelLayerProps> {
}

updateState(): void {
this._adjustedData = this.reduceCollidingLabels();
const labels = this.collectLabelGroups();

let zoomLevel = 1;
this._labelGroups.clear();
const grouping = new ProximityGrouping(labels);

for (let i = 0; i < 5; i++) {
this._labelGroups.set(
zoomLevel,
grouping
.groupEntities(100 / 2 ** zoomLevel)
.map((el) => ({ ...el, name: el.entities?.length.toString() ?? el.name }))
);
zoomLevel -= 1;
}
this._adjustedData = this.reduceCollidingLabels(labels);
}

filterSubLayer(context: FilterContext): boolean {
if (context.layer.id.includes("text")) {
return context.viewport.zoom > -2;
if (context.layer.id === `${this.props.id}-text`) {
return context.viewport.zoom > 1;
}
if (context.layer.id === `${this.props.id}-lines`) {
return context.viewport.zoom > 1;
}

const reg = /(text|points)-zoom-([-\d\\.]+)/;
const match = context.layer.id.match(reg);

if (match) {
const zoom = parseFloat(match[2]);
const zoomLevels = Array.from(this._labelGroups.keys());
const closestZoomLevel = zoomLevels.reduce((prev, curr) =>
Math.abs(curr - context.viewport.zoom) < Math.abs(prev - context.viewport.zoom) ? curr : prev
);
return closestZoomLevel === zoom && context.viewport.zoom <= 1;
}

return true;
}

getPickingInfo(params: GetPickingInfoParams): LabelPickingInfo {
const info = super.getPickingInfo(params) as LabelPickingInfo;
const { index, sourceLayer } = info;
if (index >= 0 && sourceLayer) {
info.object.name = `${this._adjustedData[index].name}\n${this._adjustedData[index].otherNames.join("\n")}`;
}
return info;
}

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

const zoomLayers: Layer<any>[] = [];

for (const [zoomLevel, labelGroups] of this._labelGroups) {
zoomLayers.push(
new PointsLayer(
this.getSubLayerProps({
id: `points-zoom-${zoomLevel}`,
pointsData: labelGroups.flatMap((d) => [...d.coordinates, 0]),
pointRadius: 100 / 2 ** zoomLevel,
color: [255, 255, 255, 30],
radiusUnits: "meters",
})
)
);
zoomLayers.push(
new TextLayer(
this.getSubLayerProps({
id: `text-zoom-${zoomLevel}`,
data: labelGroups,
getPosition: (d: ExtendedLabelData) => d.coordinates,
getText: (d: ExtendedLabelData) => `${d.name}`,
getSize: 16,
getColor: [255, 255, 255],
getAngle: 0,
getPixelOffset: [0, 0],
fontWeight: 800,
getTextAnchor: "middle",
getAlignmentBaseline: "center",
pickable: true,
sizeScale: Math.abs(zoomLevel - 3) ** 2,
sizeUnits: "meters",
sizeMinPixels: sizeMinPixels,
sizeMaxPixels: 24,
fontSettings: {
sdf: true,
},
})
)
);
}

return [
new PointsLayer(
this.getSubLayerProps({
Expand All @@ -164,8 +248,13 @@ export class LabelLayer extends CompositeLayer<LabelLayerProps> {
getColor: [0, 0, 0],
getLineWidth: 1,
sizeUnits: "pixels",
sizeMinPixels: sizeMinPixels,
sizeMaxPixels: sizeMaxPixels,
collisionGroup: "label",
collisionTestProps: {
widthMaxPixels: 0.001,
widthMinPixels: 0.001,
},
collisionEnabled: true,
autoHighlight: true,
})
),
new TextLayer(
Expand All @@ -177,8 +266,6 @@ export class LabelLayer extends CompositeLayer<LabelLayerProps> {
`${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,
Expand All @@ -187,14 +274,18 @@ export class LabelLayer extends CompositeLayer<LabelLayerProps> {
fontSettings: {
fontSize: 16,
},
pickable: true,
sizeScale: 1,
sizeUnits: "meters",
sizeMinPixels: sizeMinPixels,
sizeMaxPixels: sizeMaxPixels,
getBackgroundColor: [0, 0, 0, 255],
background: true,
autoHighlight: true,
highlightColor: [0, 0, 255, 255],
})
),
...zoomLayers,
/*
new PolygonLayer(
this.getSubLayerProps({
Expand Down

0 comments on commit 68946a5

Please sign in to comment.