forked from equinor/webviz
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
3ddc0af
commit 65faf4d
Showing
4 changed files
with
451 additions
and
39 deletions.
There are no files selected for viewing
148 changes: 148 additions & 0 deletions
148
frontend/src/lib/utils/ForceDirectedEntityPositioning.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
219
frontend/src/modules/2DViewer/view/customDeckGlLayers/LabelLayer.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
}) | ||
), | ||
*/ | ||
]; | ||
} | ||
} |
Oops, something went wrong.