UNPKG

@equinor/esv-intersection

Version:

Intersection component package with testing and automatic documentation.

619 lines (541 loc) 21.4 kB
import Vector2 from '@equinor/videx-vector2'; import { clamp } from '@equinor/videx-math'; import { CanvasLayer } from './base/CanvasLayer'; import { OnUpdateEvent, OnRescaleEvent, OnMountEvent } from '../interfaces'; import { SurfaceArea, SurfaceLine, findSampleAtPos, SurfaceData } from '../datautils'; import { SURFACE_LINE_WIDTH } from '../constants'; import { LayerOptions } from './base/Layer'; const DEFAULT_MARGINS = 18; const DEFAULT_MIN_FONT_SIZE = 8; const DEFAULT_MAX_FONT_SIZE = 13; const DEFAULT_TEXT_COLOR = 'black'; const DEFAULT_FONT = 'Arial'; const MAX_FONT_SIZE_IN_WORLD_COORDINATES = 70; export interface GeomodelLayerLabelsOptions<T extends SurfaceData> extends LayerOptions<T> { margins?: number; minFontSize?: number; maxFontSize?: number; textColor?: string; font?: string; } interface SurfaceAreaWithAvgTopDepth extends SurfaceArea { avgTopDepth: number; } export class GeomodelLabelsLayer<T extends SurfaceData> extends CanvasLayer<T> { defaultMargins: number = DEFAULT_MARGINS; defaultMinFontSize: number = DEFAULT_MIN_FONT_SIZE; defaultMaxFontSize: number = DEFAULT_MAX_FONT_SIZE; defaultTextColor: string = DEFAULT_TEXT_COLOR; defaultFont: string = DEFAULT_FONT; rescaleEvent: OnRescaleEvent | undefined; isLabelsOnLeftSide = true; maxFontSizeInWorldCoordinates: number = MAX_FONT_SIZE_IN_WORLD_COORDINATES; isXFlipped = false; areasWithAvgTopDepth: SurfaceAreaWithAvgTopDepth[] = []; constructor(id?: string, options?: GeomodelLayerLabelsOptions<T>) { super(id, options); this.render = this.render.bind(this); this.getMarginsInWorldCoordinates = this.getMarginsInWorldCoordinates.bind(this); this.getSurfacesAreaEdges = this.getSurfacesAreaEdges.bind(this); this.updateXFlipped = this.updateXFlipped.bind(this); this.generateSurfacesWithAvgDepth = this.generateSurfacesWithAvgDepth.bind(this); } override get options(): GeomodelLayerLabelsOptions<T> { return this._options; } override setData(data: T): void { super.setData(data); this.areasWithAvgTopDepth = []; } generateSurfacesWithAvgDepth(): void { const areas = this.data?.areas ?? []; this.areasWithAvgTopDepth = areas.reduce((acc: SurfaceAreaWithAvgTopDepth[], area: SurfaceArea) => { // Filter surfaces without label if (!area.label) { return acc; } const sumAndCount = area.data.reduce( (a: { sum: number; count: number }, d: number[]) => { if (d[1] != null) { a.sum += d[1]; a.count++; } return a; }, { sum: 0, count: 0, }, ); if (sumAndCount.count === 0) { return acc; } const avgTopDepth = sumAndCount.sum / sumAndCount.count; acc.push({ ...area, avgTopDepth, }); return acc; }, []); } override onMount(event: OnMountEvent): void { super.onMount(event); } override onUpdate(event: OnUpdateEvent<T>): void { super.onUpdate(event); this.render(); } override onRescale(event: OnRescaleEvent): void { this.rescaleEvent = event; this.updateXFlipped(); this.resetTransform(); this.render(); } render(): void { if (!this.rescaleEvent) { return; } requestAnimationFrame(() => { this.clearCanvas(); if (!this.data) { return; } if (this.areasWithAvgTopDepth.length <= 0) { this.generateSurfacesWithAvgDepth(); } this.drawAreaLabels(); this.drawLineLabels(); }); } drawAreaLabels(): void { this.areasWithAvgTopDepth.forEach((s: SurfaceAreaWithAvgTopDepth, i: number, array: SurfaceAreaWithAvgTopDepth[]) => { const topmostSurfaceNotDrawnYet = array.reduce((acc: SurfaceAreaWithAvgTopDepth | null, v, index): SurfaceAreaWithAvgTopDepth | null => { if (index > i) { if (acc == null) { acc = v; } else { if (v.avgTopDepth < acc.avgTopDepth) { acc = v; } } } return acc; }, null); this.drawAreaLabel(s, topmostSurfaceNotDrawnYet, array, i); }); } drawLineLabels(): void { this.data?.lines.filter((surfaceLine: SurfaceLine) => surfaceLine.label).forEach((surfaceLine: SurfaceLine) => this.drawLineLabel(surfaceLine)); } drawAreaLabel = (surfaceArea: SurfaceArea, nextSurfaceArea: SurfaceArea | null, surfaces: SurfaceArea[], i: number): void => { const { data } = surfaceArea; const { ctx, maxFontSizeInWorldCoordinates, isXFlipped } = this; const { xScale, yScale, xRatio, yRatio, zFactor } = this.rescaleEvent!; if (ctx == null) return; let isLabelsOnLeftSide = this.checkDrawLabelsOnLeftSide(); const margins = (this.options.margins || this.defaultMargins) * (isXFlipped ? -1 : 1); const marginsInWorldCoords = margins / xRatio; const minFontSize = this.options.minFontSize || this.defaultMinFontSize; const maxFontSize = this.options.maxFontSize || this.defaultMaxFontSize; let fontSizeInWorldCoords = maxFontSize / yRatio; if (fontSizeInWorldCoords > maxFontSizeInWorldCoordinates) { fontSizeInWorldCoords = maxFontSizeInWorldCoordinates; if (fontSizeInWorldCoords * yRatio < minFontSize) { fontSizeInWorldCoords = minFontSize / yRatio; } } const leftEdge = xScale.invert(xScale.range()[0]!) + marginsInWorldCoords; const rightEdge = xScale.invert(xScale.range()[1]!) - marginsInWorldCoords; const [surfaceAreaLeftEdge, surfaceAreaRightEdge] = this.getSurfacesAreaEdges() as [number, number]; // Get label metrics ctx.save(); ctx.font = `${fontSizeInWorldCoords * yRatio}px ${this.options.font || this.defaultFont}`; let labelMetrics = ctx.measureText(surfaceArea.label ?? ''); let labelLengthInWorldCoords = labelMetrics.width / xRatio; // Check if label will fit horizontally if (isLabelsOnLeftSide) { const labelRightEdge = leftEdge + (isXFlipped ? -labelLengthInWorldCoords : labelLengthInWorldCoords); if ((!isXFlipped && labelRightEdge > surfaceAreaRightEdge) || (isXFlipped && labelRightEdge < surfaceAreaRightEdge)) { isLabelsOnLeftSide = false; } } else { const labelLeftEdge = rightEdge + (isXFlipped ? labelLengthInWorldCoords : -labelLengthInWorldCoords); if ((!isXFlipped && labelLeftEdge < surfaceAreaLeftEdge) || (isXFlipped && labelLeftEdge > surfaceAreaLeftEdge)) { isLabelsOnLeftSide = true; } } // Find edge where to draw let startPos: number; const portionOfLabelLengthUsedForPosCalc = 0.07; if (isLabelsOnLeftSide) { startPos = isXFlipped ? Math.min(surfaceAreaLeftEdge, leftEdge) : Math.max(surfaceAreaLeftEdge, leftEdge); } else { startPos = isXFlipped ? Math.max(surfaceAreaRightEdge, rightEdge) : Math.min(surfaceAreaRightEdge, rightEdge); } const topEdge = yScale.invert(yScale.range()[0]!); const bottomEdge = yScale.invert(yScale.range()[1]!); // Calculate where to sample points const dirSteps = 5; const posSteps = 3; const posStep = portionOfLabelLengthUsedForPosCalc * (labelLengthInWorldCoords / posSteps) * (isLabelsOnLeftSide ? 1 : -1) * (isXFlipped ? -1 : 1); const dirStep = (labelLengthInWorldCoords / dirSteps) * (isLabelsOnLeftSide ? 1 : -1) * (isXFlipped ? -1 : 1); // Sample points from top and calculate position const topData = data.map((d) => [d[0]!, d[1]!]); const topPos = this.calcPos(topData, startPos, posSteps, posStep, topEdge, bottomEdge); if (!topPos) { return; } // Sample points from bottom and calculate position const bottomData = data.map((d) => [d[0]!, d[2]!]); let bottomPos = this.calcPos( bottomData, startPos, posSteps, posStep, topEdge, bottomEdge, nextSurfaceArea?.data.map((d) => [d[0]!, d[1]!]) ?? [], surfaces, i, ); if (!bottomPos) { bottomPos = new Vector2(topPos.x, bottomEdge); } // Check if there is enough height for label const thickness = bottomPos.y - topPos.y; if (thickness < fontSizeInWorldCoords) { // Check minimum fontsize if (thickness * yRatio < minFontSize) { return; } // Use reduced fontsize fontSizeInWorldCoords = thickness; ctx.font = `${fontSizeInWorldCoords * yRatio}px ${this.options.font || this.defaultFont}`; labelMetrics = ctx.measureText(surfaceArea.label ?? ''); labelLengthInWorldCoords = labelMetrics.width / xRatio; } // Sample points from top and bottom and calculate direction vector const initialDirVec = isLabelsOnLeftSide !== isXFlipped ? Vector2.right : Vector2.left; const areaDir = this.calcAreaDir( topData, bottomData, startPos, dirSteps, dirStep, initialDirVec, topEdge, bottomEdge, 0, Math.PI / 4, 4, nextSurfaceArea?.data.map((d) => [d[0]!, d[1]!]) ?? [], surfaces, i, ); const scaledAngle = Math.atan(Math.tan(areaDir) * zFactor); // Draw label const textX = startPos; const textY = (topPos.y + bottomPos.y) / 2; const textAngle = isXFlipped ? -scaledAngle : scaledAngle; if (ctx) { ctx.textAlign = isLabelsOnLeftSide ? 'left' : 'right'; ctx.translate(xScale(textX), yScale(textY)); ctx.rotate(textAngle); ctx.fillStyle = this.options.textColor || this.defaultTextColor; ctx.font = `${fontSizeInWorldCoords * yRatio}px ${this.options.font || this.defaultFont}`; ctx.textBaseline = 'middle'; ctx.fillText(surfaceArea.label ?? '', 0, 0); ctx.restore(); } }; drawLineLabel = (s: SurfaceLine): void => { const { ctx, isXFlipped } = this; const { xScale, yScale, xRatio, yRatio, zFactor } = this.rescaleEvent!; if (ctx == null) return; const isLabelsOnLeftSide = this.checkDrawLabelsOnLeftSide(); const marginsInWorldCoords = this.getMarginsInWorldCoordinates(); const maxFontSize = this.options.maxFontSize || this.defaultMaxFontSize; const fontSizeInWorldCoords = maxFontSize / yRatio; ctx.save(); ctx.font = `${fontSizeInWorldCoords * yRatio}px ${this.options.font || this.defaultFont}`; const labelMetrics = ctx.measureText(s.label); const labelLengthInWorldCoords = labelMetrics.width / xRatio; const leftEdge = xScale.invert(xScale.range()[0]!) + marginsInWorldCoords; const rightEdge = xScale.invert(xScale.range()[1]!) - marginsInWorldCoords; const [surfaceAreaLeftEdge, surfaceAreaRightEdge] = this.getSurfacesAreaEdges() as [number, number]; // Find edge where to draw let startPos: number; const steps = 5; if (isLabelsOnLeftSide) { startPos = isXFlipped ? Math.max(surfaceAreaRightEdge, rightEdge) : Math.min(surfaceAreaRightEdge, rightEdge); } else { startPos = isXFlipped ? Math.min(surfaceAreaLeftEdge, leftEdge) : Math.max(surfaceAreaLeftEdge, leftEdge); } // Calculate where to sample points const step = (labelLengthInWorldCoords / steps) * (isLabelsOnLeftSide ? -1 : 1); // Sample points and calculate position and direction vector const { data } = s; const pos = this.calcPos(data, startPos, steps, step); const dir = this.calcLineDir(data, startPos, steps, step, zFactor, isLabelsOnLeftSide ? Vector2.left : Vector2.right); if (!pos || !dir) { return; } // Calculate position and direction for label const textX = startPos; const textY = pos.y - SURFACE_LINE_WIDTH - fontSizeInWorldCoords / 2; const textDir = Vector2.angleRight(dir) - (isLabelsOnLeftSide ? Math.PI : 0); // Draw label if (ctx) { ctx.textAlign = isLabelsOnLeftSide ? 'right' : 'left'; ctx.translate(xScale(textX), yScale(textY)); ctx.rotate(textDir); ctx.fillStyle = this.colorToCSSColor(s.color); ctx.textBaseline = 'middle'; ctx.fillText(s.label, 0, 0); ctx.restore(); } }; colorToCSSColor(color: number | string): string { if (typeof color === 'string') { return color; } let hexString = color.toString(16); hexString = '000000'.substr(0, 6 - hexString.length) + hexString; return `#${hexString}`; } calcPos( data: number[][], offset: number, count: number, step: number, topLimit?: number, bottomLimit?: number, alternativeSurfaceData?: number[][], surfaces: SurfaceArea[] | null = null, currentSurfaceIndex?: number, ): Vector2 | null { const pos = Vector2.zero.mutable; let samples = 0; for (let i = 0; i < count; i++) { const x = offset + i * step; const y = findSampleAtPos(data, x, topLimit, bottomLimit); if (y) { const alternativeY = this.getAlternativeYValueIfAvailable(x, topLimit, bottomLimit, alternativeSurfaceData, surfaces, currentSurfaceIndex); // Use topmost of value from current surface and alternative surface const usedY = alternativeY ? Math.min(y, alternativeY) : y; pos.add(x, usedY); samples++; } } if (samples === 0) { return null; } return Vector2.divide(pos, samples); } getAlternativeYValueIfAvailable( x: number, topLimit?: number, bottomLimit?: number, alternativeSurfaceData?: number[][], surfaces: SurfaceArea[] | null = null, currentSurfaceIndex?: number, ): number | null { if (!alternativeSurfaceData) { return null; } // Find sample from passed in surface data let altY = findSampleAtPos(alternativeSurfaceData, x, topLimit, bottomLimit); if (altY == null && surfaces && currentSurfaceIndex != null) { //Find topmost surface after current which gives us data let si = currentSurfaceIndex + 1; while (altY == null && si < surfaces.length) { const altSurface = surfaces[si++]; altY = findSampleAtPos(altSurface?.data.map((d: number[]) => [d[0]!, d[1]!]) ?? [], x, topLimit, bottomLimit); } } return altY; } calcLineDir( data: number[][], offset: number, count: number, step: number, zFactor: number, initalVector: Vector2 = Vector2.left, topLimit?: number, bottomLimit?: number, ): Vector2 { const dir = initalVector.mutable; const startY = findSampleAtPos(data, offset, topLimit, bottomLimit); if (startY === null) { return dir; } const vecAtEnd = new Vector2(offset, startY * zFactor); const tmpVec = Vector2.zero.mutable; for (let i = 1; i <= count; i++) { const x = offset + i * step; const y = findSampleAtPos(data, offset, topLimit, bottomLimit); if (y !== null) { tmpVec.set(x, y * zFactor); tmpVec.sub(vecAtEnd); dir.add(tmpVec); } } return dir; } calcAreaDir( top: number[][], bottom: number[][], offset: number, count: number, step: number, initalVector: Vector2 = Vector2.left, topLimit: number, bottomLimit: number, minReductionAngle = 0, maxReductionAngle: number = Math.PI / 4, angleReductionExponent = 4, alternativeSurfaceBottomData: number[][], surfaces: SurfaceArea[] | null = null, currentSurfaceIndex: number, ): number { const angles: number[] = []; const tmpVec = Vector2.zero.mutable; let vecAtEnd: Vector2; for (let i = 0; i <= count; i++) { const x = offset + i * step; const topY = findSampleAtPos(top, x, topLimit, bottomLimit); const bottomY = findSampleAtPos(bottom, x, topLimit, bottomLimit) || bottomLimit; // Find position of next surface in case it's higher than current base const alternativeBottomY = this.getAlternativeYValueIfAvailable( x, topLimit, bottomLimit, alternativeSurfaceBottomData, surfaces, currentSurfaceIndex, ); // Use topmost of value from current surface and alternative surface const usedBottomY = alternativeBottomY ? Math.min(bottomY, alternativeBottomY) : bottomY; if (i === 0) { if (topY === null) { return Vector2.angleRight(initalVector); } const startY = (topY + usedBottomY) / 2; vecAtEnd = new Vector2(offset, startY); } else { if (topY !== null) { tmpVec.set(x, (topY + usedBottomY) / 2); tmpVec.sub(vecAtEnd!); angles.push(Vector2.angleRight(tmpVec)); } else { angles.push(Vector2.angleRight(initalVector)); } } } const refAngle = angles[0]!; const offsetAngles = angles.map((d: number) => d - refAngle); let factors = 0; const offsetSum = offsetAngles.reduce((acc: number, v: number) => { const ratio = (Math.abs(v) - minReductionAngle) / maxReductionAngle; const factor = Math.pow(1 - clamp(ratio, 0, 1), angleReductionExponent); factors += factor; return acc + v * factor; }, 0); const angle = offsetSum / factors + refAngle; return angle; } updateXFlipped(): void { const { xBounds } = this.rescaleEvent!; this.isXFlipped = xBounds[0] > xBounds[1]; } getMarginsInWorldCoordinates(): number { const { xRatio } = this.rescaleEvent!; const margins = (this.options.margins || this.defaultMargins) * (this.isXFlipped ? -1 : 1); const marginsInWorldCoords = margins / xRatio; return marginsInWorldCoords; } getSurfacesAreaEdges(): number[] { const endPoints = this.data?.areas.reduce((acc, area) => { const { data } = area; const firstValidPoint = data.find((d: number[]) => d[1] != null); if (firstValidPoint) { acc.push(firstValidPoint[0]!); } // TODO: Use findLast() when TypeScript stops complaining about it for (let i = data.length - 1; i >= 0; i--) { if (data[i]?.[1] != null) { acc.push(data[i]?.[0]!); break; } } return acc; }, [] as number[]) ?? []; endPoints.push( ...(this.data?.lines.reduce((acc, line) => { const { data } = line; const firstValidPoint = data.find((d: number[]) => d[1] != null); if (firstValidPoint) { acc.push(firstValidPoint[0]!); } // TODO: Use findLast() when TypeScript stops complaining about it for (let i = data.length - 1; i >= 0; i--) { if (data[i]?.[1] != null) { acc.push(data[i]?.[0]!); break; } } return acc; }, [] as number[]) ?? []), ); const minX = Math.min(...endPoints); const maxX = Math.max(...endPoints); const marginsInWorldCoords = this.getMarginsInWorldCoordinates(); const { isXFlipped } = this; const surfaceAreaLeftEdge = isXFlipped ? maxX + marginsInWorldCoords : minX + marginsInWorldCoords; const surfaceAreaRightEdge = isXFlipped ? minX - marginsInWorldCoords : maxX - marginsInWorldCoords; return [surfaceAreaLeftEdge, surfaceAreaRightEdge]; } checkDrawLabelsOnLeftSide(): boolean { const { referenceSystem, isXFlipped } = this; if (!referenceSystem) { return true; } const { xScale, yScale, xRatio } = this.rescaleEvent!; const t = 200; // TODO: Use actual size of largest label or average size of all const [dx1, dx2] = xScale.domain() as [number, number]; const [dy1, dy2] = yScale.domain() as [number, number]; let top = referenceSystem.interpolators.curtain.getIntersects(dy1, 1, 0) as number[][]; if (top.length === 0) { top = [referenceSystem.interpolators.curtain.getPointAt(0.0) as number[]]; } let bottom = referenceSystem.interpolators.curtain.getIntersects(dy2, 1, 0) as number[][]; if (bottom.length === 0) { bottom = [referenceSystem.interpolators.curtain.getPointAt(1.0) as number[]]; } const maxX = Math.max(top[0]?.[0]!, bottom[0]?.[0]!); const minX = Math.min(top[0]?.[0]!, bottom[0]?.[0]!); const wbBBox = { left: isXFlipped ? maxX : minX, right: isXFlipped ? minX : maxX, }; const margin = this.getMarginsInWorldCoordinates(); const screenLeftEdge = dx1 + margin; const screenRightEdge = dx2 - margin; const [surfaceAreaLeftEdge, surfaceAreaRightEdge] = this.getSurfacesAreaEdges() as [number, number]; const leftLimit = isXFlipped ? Math.min(screenLeftEdge, surfaceAreaLeftEdge) : Math.max(screenLeftEdge, surfaceAreaLeftEdge); const rightLimit = isXFlipped ? Math.max(screenRightEdge, surfaceAreaRightEdge) : Math.min(screenRightEdge, surfaceAreaRightEdge); const spaceOnLeftSide = Math.max(isXFlipped ? leftLimit - wbBBox.left : wbBBox.left - leftLimit, 0); const spaceOnRightSide = Math.max(isXFlipped ? wbBBox.right - rightLimit : rightLimit - wbBBox.right, 0); const spaceOnLeftSideInScreenCoordinates = spaceOnLeftSide * xRatio; const spaceOnRightSideInScreenCoordinates = spaceOnRightSide * xRatio; const isLabelsOnLeftSide = spaceOnLeftSide > spaceOnRightSide || spaceOnLeftSideInScreenCoordinates > t || (spaceOnLeftSideInScreenCoordinates < t && spaceOnRightSideInScreenCoordinates < t && isXFlipped) || bottom[0]?.[1]! < dy1; return isLabelsOnLeftSide; } }