From 65faf4d9d4dd73380f1d5b27f3e40cb25e2f5f37 Mon Sep 17 00:00:00 2001 From: Ruben Thoms Date: Wed, 20 Nov 2024 17:21:39 +0100 Subject: [PATCH] wip --- .../utils/ForceDirectedEntityPositioning.ts | 148 ++++++++++++ .../view/customDeckGlLayers/LabelLayer.ts | 219 ++++++++++++++++++ .../2DViewer/view/utils/layerFactory.ts | 15 +- frontend/src/modules/MyModule/view.tsx | 108 ++++++--- 4 files changed, 451 insertions(+), 39 deletions(-) create mode 100644 frontend/src/lib/utils/ForceDirectedEntityPositioning.ts create mode 100644 frontend/src/modules/2DViewer/view/customDeckGlLayers/LabelLayer.ts diff --git a/frontend/src/lib/utils/ForceDirectedEntityPositioning.ts b/frontend/src/lib/utils/ForceDirectedEntityPositioning.ts new file mode 100644 index 000000000..b07a4ec6d --- /dev/null +++ b/frontend/src/lib/utils/ForceDirectedEntityPositioning.ts @@ -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 { + 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]; + } +} diff --git a/frontend/src/modules/2DViewer/view/customDeckGlLayers/LabelLayer.ts b/frontend/src/modules/2DViewer/view/customDeckGlLayers/LabelLayer.ts new file mode 100644 index 000000000..5eae77190 --- /dev/null +++ b/frontend/src/modules/2DViewer/view/customDeckGlLayers/LabelLayer.ts @@ -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 { + 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", + }) + ), + */ + ]; + } +} diff --git a/frontend/src/modules/2DViewer/view/utils/layerFactory.ts b/frontend/src/modules/2DViewer/view/utils/layerFactory.ts index fc03f2de5..55e28832f 100644 --- a/frontend/src/modules/2DViewer/view/utils/layerFactory.ts +++ b/frontend/src/modules/2DViewer/view/utils/layerFactory.ts @@ -20,7 +20,8 @@ import { RealizationSurfaceLayer } from "../../layers/implementations/layers/Rea import { StatisticalSurfaceLayer } from "../../layers/implementations/layers/StatisticalSurfaceLayer/StatisticalSurfaceLayer"; import { Layer as LayerInterface } from "../../layers/interfaces"; import { AdvancedWellsLayer } from "../customDeckGlLayers/AdvancedWellsLayer"; -import { WellBorePickLayerData, WellborePicksLayer } from "../customDeckGlLayers/WellborePicksLayer"; +import { LabelLayer } from "../customDeckGlLayers/LabelLayer"; +import { WellBorePickLayerData } from "../customDeckGlLayers/WellborePicksLayer"; export function makeLayer(layer: LayerInterface, colorScale?: ColorScaleWithName): Layer | null { const data = layer.getLayerDelegate().getData(); @@ -82,7 +83,7 @@ export function makeLayer(layer: LayerInterface, colorScale?: ColorSca } return null; } -function createWellPicksLayer(wellPicksDataApi: WellborePick_api[], id: string): WellborePicksLayer { +function createWellPicksLayer(wellPicksDataApi: WellborePick_api[], id: string): LabelLayer { const wellPicksData: WellBorePickLayerData[] = wellPicksDataApi.map((wellPick) => { return { easting: wellPick.easting, @@ -94,10 +95,20 @@ function createWellPicksLayer(wellPicksDataApi: WellborePick_api[], id: string): slotName: "", }; }); + /* return new WellborePicksLayer({ id: id, data: wellPicksData, pickable: true, + });*/ + return new LabelLayer({ + id: id, + data: wellPicksData.map((wellPick) => { + return { + coordinates: [wellPick.easting, wellPick.northing, wellPick.tvdMsl], + name: wellPick.wellBoreUwi, + }; + }), }); } diff --git a/frontend/src/modules/MyModule/view.tsx b/frontend/src/modules/MyModule/view.tsx index f07a894df..04d7da7c5 100644 --- a/frontend/src/modules/MyModule/view.tsx +++ b/frontend/src/modules/MyModule/view.tsx @@ -1,13 +1,7 @@ import React from "react"; -import Plot from "react-plotly.js"; -import { ModuleViewProps } from "@framework/Module"; import { useElementSize } from "@lib/hooks/useElementSize"; -import { ColorScaleType } from "@lib/utils/ColorScale"; - -import { PlotData } from "plotly.js"; - -import { Interfaces } from "./interfaces"; +import { Entity, ForceDirectedEntityPositioning } from "@lib/utils/ForceDirectedEntityPositioning"; const countryData = [ "Belarus", @@ -402,46 +396,86 @@ for (let i = 0; i < countryData.length; i += 2) { alcConsumption.push(countryData[i + 1] as number); } -export function View(props: ModuleViewProps): React.ReactNode { - const type = props.viewContext.useSettingsToViewInterfaceValue("type"); - const gradientType = props.viewContext.useSettingsToViewInterfaceValue("gradientType"); - const min = props.viewContext.useSettingsToViewInterfaceValue("min"); - const max = props.viewContext.useSettingsToViewInterfaceValue("max"); - const divMidPoint = props.viewContext.useSettingsToViewInterfaceValue("divMidPoint"); +type Label = Entity & { + name: string; + width: number; + height: number; +}; +export function View(): React.ReactNode { const ref = React.useRef(null); + const [labels, setLabels] = React.useState([]); const size = useElementSize(ref); - const colorScale = - type === ColorScaleType.Continuous - ? props.workbenchSettings.useContinuousColorScale({ - gradientType, - }) - : props.workbenchSettings.useDiscreteColorScale({ - gradientType, - }); + function autoGenerateSetup(): void { + const newLabels: Label[] = []; + + const numPoints = 50; + + for (let i = 0; i < numPoints; i++) { + const point: [number, number] = [Math.random() * size.width, Math.random() * size.height]; + + newLabels.push({ + name: `Label ${i}`, + coordinates: point, + anchorCoordinates: point, + width: 100, + height: 24, + }); + } + + setLabels(newLabels); + } - colorScale.setRangeAndMidPoint(min, max, divMidPoint); + function repositionLabels() { + const forceDirectedEntityPositioning = new ForceDirectedEntityPositioning(labels, { + springConstant: 0.00001, + chargeConstant: 50, + springRestLength: 1, + tolerance: 0.5, + }); - const data: Partial = { - ...colorScale.getAsPlotlyColorScaleMapObject(), - type: "choropleth", - locationmode: "country names", - locations: countries, - z: alcConsumption, - }; + const adjustedLabels = forceDirectedEntityPositioning.run(); - const layout = { - mapbox: { style: "dark", center: { lon: -110, lat: 50 }, zoom: 0.8 }, - width: size.width, - height: size.height, - margin: { t: 0, b: 0 }, - }; + setLabels(adjustedLabels); + } return ( -
- +
+
+ + +
+ + {labels.map((label) => ( + + + + + + ))} +
); }