diff --git a/frontend/src/lib/utils/ForceDirectedEntityPositioning.ts b/frontend/src/lib/utils/ForceDirectedEntityPositioning.ts index b07a4ec6d..982498728 100644 --- a/frontend/src/lib/utils/ForceDirectedEntityPositioning.ts +++ b/frontend/src/lib/utils/ForceDirectedEntityPositioning.ts @@ -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; @@ -80,7 +81,7 @@ export class ForceDirectedEntityPositioning { 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; @@ -91,7 +92,7 @@ export class ForceDirectedEntityPositioning { 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]; @@ -115,34 +116,44 @@ export class ForceDirectedEntityPositioning { } 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]; } } diff --git a/frontend/src/lib/utils/ProximityGrouping.ts b/frontend/src/lib/utils/ProximityGrouping.ts new file mode 100644 index 000000000..a2f33ccc0 --- /dev/null +++ b/frontend/src/lib/utils/ProximityGrouping.ts @@ -0,0 +1,55 @@ +export interface Entity { + coordinates: [number, number]; +} + +export type EntityGroup = Entity & + TEntity & { + entities?: TEntity[]; + }; + +export class ProximityGrouping { + 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[] { + const groups: EntityGroup[] = []; + + for (let i = 0; i < this._entities.length; i++) { + const entity = this._entities[i]; + let group: EntityGroup | 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; + } +} diff --git a/frontend/src/modules/2DViewer/view/customDeckGlLayers/LabelLayer.ts b/frontend/src/modules/2DViewer/view/customDeckGlLayers/LabelLayer.ts index 5eae77190..10f3de322 100644 --- a/frontend/src/modules/2DViewer/view/customDeckGlLayers/LabelLayer.ts +++ b/frontend/src/modules/2DViewer/view/customDeckGlLayers/LabelLayer.ts @@ -1,6 +1,7 @@ -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 = { @@ -8,12 +9,10 @@ type LabelData = { name: string; }; -type IntermediateLabelData = { +interface IntermediateLabelData extends Entity { name: string; otherNames: string[]; - coordinates: [number, number]; - anchorCoordinates: [number, number]; -}; +} type ExtendedLabelData = { name: string; @@ -35,11 +34,16 @@ type BoundingBox2D = { bottomRight: number[]; }; +export type LabelPickingInfo = PickingInfo & { + additionalText?: string; +}; + export class LabelLayer extends CompositeLayer { static layerName: string = "LabelLayer"; private _labelBoundingBoxes: (BoundingBox2D | null)[] = []; private _adjustedData: ExtendedLabelData[] = []; + private _labelGroups: Map[]> = new Map(); estimateLabelBoundingBoxes(): void { const viewport = this.context.viewport; @@ -100,6 +104,7 @@ export class LabelLayer extends CompositeLayer { coordinates: [xWorld, yWorld], anchorCoordinates: [xWorld, yWorld], otherNames: [], + chargeMagnitude: label.name.length, }); } } @@ -107,12 +112,11 @@ export class LabelLayer extends CompositeLayer { 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, }); @@ -128,21 +132,101 @@ export class LabelLayer extends CompositeLayer { } 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[] = []; + + 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({ @@ -164,8 +248,13 @@ export class LabelLayer extends CompositeLayer { 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( @@ -177,8 +266,6 @@ export class LabelLayer extends CompositeLayer { `${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, @@ -187,14 +274,18 @@ export class LabelLayer extends CompositeLayer { 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({