UNPKG

@equinor/esv-intersection

Version:

Intersection component package with testing and automatic documentation.

534 lines (465 loc) 13.7 kB
import { ScaleLinear } from 'd3-scale'; import { CanvasLayer } from './base/CanvasLayer'; import { OnUpdateEvent, Annotation, OnRescaleEvent, BoundingBox, } from '../interfaces'; import { calcSize, isOverlapping, getOverlapOffset } from '../utils'; import { LayerOptions } from './base/Layer'; const DEFAULT_MIN_FONT_SIZE = 7; const DEFAULT_MAX_FONT_SIZE = 11; const DEFAULT_FONT_SIZE_FACTOR = 7; const DEFAULT_OFFSET_MIN = 20; const DEFAULT_OFFSET_MAX = 120; const DEFAULT_OFFSET_FACTOR = 19; const DEFAULT_BACKGROUND_COLOR = 'rgba(0, 0, 0, 0.5)'; const DEFAULT_BACKGROUND_PADDING = 5; const DEFAULT_BACKGROUND_BORDER_RADIUS = 5; /** Input returned if present, defaultValue used as fallback. */ function getValueOrDefault<T>(input: T | null | undefined, defaultValue: T): T { return input === null || input === undefined ? defaultValue : input; } const Location = { topleft: 'topleft', topright: 'topright', bottomleft: 'bottomleft', bottomright: 'bottomright', }; export type Point = { x: number; y: number; }; export type Callout = { title: string; label: string; color: string; pos: Point; group: string; alignment: string; boundingBox: BoundingBox; dx: number; dy: number; }; export interface CalloutOptions< T extends Annotation[], > extends LayerOptions<T> { minFontSize?: number; maxFontSize?: number; fontSizeFactor?: number; offsetMin?: number; offsetMax?: number; offsetFactor?: number; fontColor?: string; backgroundColor?: string; backgroundPadding?: number; backgroundBorderRadius?: number; } export class CalloutCanvasLayer<T extends Annotation[]> extends CanvasLayer<T> { rescaleEvent: OnRescaleEvent | undefined; xRatio: number | undefined; callouts: Callout[] = []; groupFilter: string[] = []; minFontSize: number; maxFontSize: number; fontSizeFactor: number; offsetMin: number; offsetMax: number; offsetFactor: number; fontColor: string | undefined; backgroundActive: boolean; backgroundColor: string; backgroundPadding: number; backgroundBorderRadius: number; constructor(id?: string, options?: CalloutOptions<T>) { super(id, options); this.minFontSize = options?.minFontSize || DEFAULT_MIN_FONT_SIZE; this.maxFontSize = options?.maxFontSize || DEFAULT_MAX_FONT_SIZE; this.fontSizeFactor = options?.fontSizeFactor || DEFAULT_FONT_SIZE_FACTOR; this.offsetMin = options?.offsetMin || DEFAULT_OFFSET_MIN; this.offsetMax = options?.offsetMax || DEFAULT_OFFSET_MAX; this.offsetFactor = options?.offsetFactor || DEFAULT_OFFSET_FACTOR; this.fontColor = options?.fontColor; // Set background as active if 'backgroundColor' is defined if (options?.backgroundColor) { this.backgroundActive = true; this.backgroundColor = options.backgroundColor; } else { this.backgroundActive = false; this.backgroundColor = DEFAULT_BACKGROUND_COLOR; } this.backgroundPadding = options?.backgroundPadding || DEFAULT_BACKGROUND_PADDING; this.backgroundBorderRadius = getValueOrDefault( options?.backgroundBorderRadius, DEFAULT_BACKGROUND_BORDER_RADIUS, ); } setGroupFilter(filter: string[]): void { this.groupFilter = filter; this.callouts = []; this.render(); } override onUpdate(event: OnUpdateEvent<T>): void { super.onUpdate(event); this.callouts = []; this.render(); } override onRescale(event: OnRescaleEvent): void { super.onRescale(event); const isPanning = this.rescaleEvent && this.rescaleEvent.xRatio === event.xRatio; this.rescaleEvent = event; this.render(isPanning); } render(isPanning = false): void { requestAnimationFrame(() => { this.clearCanvas(); if (!this.data || !this.rescaleEvent || !this.referenceSystem) { return; } const { xScale, yScale, xBounds } = this.rescaleEvent; const fontSize = calcSize( this.fontSizeFactor, this.minFontSize, this.maxFontSize, xScale, ); if (!isPanning || this.callouts.length <= 0) { const { data, ctx, groupFilter } = this; const { calculateDisplacementFromBottom } = this.referenceSystem.options; const isLeftToRight = calculateDisplacementFromBottom ? xBounds[0] < xBounds[1] : xBounds[0] > xBounds[1]; const scale = 0; ctx != null && (ctx.font = `bold ${fontSize}px arial`); const filtered = data.filter( (d: Annotation) => groupFilter.length <= 0 || groupFilter.includes(d.group), ); const offset = calcSize( this.offsetFactor, this.offsetMin, this.offsetMax, xScale, ); this.callouts = this.positionCallouts( filtered, isLeftToRight, xScale, yScale, scale, fontSize, offset, ); } this.callouts.forEach(callout => { const { pos, title, color } = callout; const x = xScale(pos.x); const y = yScale(pos.y); const calloutBB = { x, y, width: callout.boundingBox.width, height: fontSize, offsetX: callout.dx, offsetY: callout.dy, }; this.renderCallout( title, callout.label, calloutBB, color, callout.alignment, ); }); }); } private renderBackground( title: string, label: string, x: number, y: number, fontSize: number, ): void { const { ctx } = this; if (ctx == null) { return; } const padding = this.backgroundPadding; const borderRadius = this.backgroundBorderRadius; const titleWidth = this.measureTextWidth(title, fontSize, 'arial', 'bold'); const labelWidth = this.measureTextWidth(label, fontSize); // Determine width and height of annotation const width = Math.max(titleWidth, labelWidth) + padding * 2; const height = (fontSize + padding) * 2; const xMin = x - padding; const yMin = y - 2 * fontSize - padding; ctx.fillStyle = this.backgroundColor; if (borderRadius > 0) { const xMax = xMin + width; const yMax = yMin + height; // Draw rounded rect ctx.beginPath(); ctx.moveTo(xMin + borderRadius, yMin); // Top left ctx.lineTo(xMax - borderRadius, yMin); ctx.quadraticCurveTo(xMax, yMin, xMax, yMin + borderRadius); // Top right corner ctx.lineTo(xMax, yMax - borderRadius); ctx.quadraticCurveTo(xMax, yMax, xMax - borderRadius, yMax); // Bottom right corner ctx.lineTo(xMin + borderRadius, yMax); ctx.quadraticCurveTo(xMin, yMax, xMin, yMax - borderRadius); // Bottom left corner ctx.lineTo(xMin, yMin + borderRadius); ctx.quadraticCurveTo(xMin, yMin, xMin + borderRadius, yMin); // Top left corner ctx.fill(); } else { // Draw rect if no border radius ctx.fillRect(xMin, yMin, width, height); } } private renderAnnotation = ( title: string, label: string, x: number, y: number, fontSize: number, color: string, ): void => { this.renderText(title, x, y - fontSize, fontSize, color, 'arial', 'bold'); this.renderText(label, x, y, fontSize, color); }; private renderText( title: string, x: number, y: number, fontSize: number, color: string, font = 'arial', fontStyle = 'normal', ): void { const { ctx } = this; if (ctx != null) { ctx.font = `${fontStyle} ${fontSize}px ${font}`; ctx.fillStyle = this.fontColor || color; ctx.fillText(title, x, y); } } private measureTextWidth( title: string, fontSize: number, font = 'arial', fontStyle = 'normal', ): number { const { ctx } = this; if (ctx == null) { return 0; } ctx.font = `${fontStyle} ${fontSize}px ${font}`; return ctx.measureText(title).width; } private renderPoint(x: number, y: number, color: string, radius = 3): void { const { ctx } = this; if (ctx != null) { ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(x, y); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fill(); } } private renderCallout( title: string, label: string, boundingBox: BoundingBox, color: string, location: string, ): void { const pos = this.getPosition(boundingBox, location); const { x, y } = pos; const { height, width, x: dotX, y: dotY } = boundingBox; const placeLeft = location === Location.topright || location === Location.bottomright; if (this.backgroundActive) { this.renderBackground(title, label, x, y, height); } this.renderAnnotation(title, label, x, y, height, color); this.renderPoint(dotX, dotY, color); this.renderLine(x, y, width, dotX, dotY, color, placeLeft); } private renderLine = ( x: number, y: number, width: number, dotX: number, dotY: number, color: string, placeLeft = true, ): void => { const { ctx } = this; const textX = placeLeft ? x : x + width; const inverseTextX = placeLeft ? x + width : x; const textY = y + 2; if (ctx != null) { ctx.strokeStyle = color; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(dotX, dotY); ctx.lineTo(textX, textY); ctx.lineTo(inverseTextX, textY); ctx.stroke(); } }; private getPosition(boundingBox: BoundingBox, location: string): Point { const { x, y, offsetX = 0, offsetY = 0, width } = boundingBox; switch (location) { case Location.topleft: return { x: x - width - offsetX, y: y - offsetY, }; case Location.topright: return { x: x + offsetX, y: y - offsetY, }; case Location.bottomleft: return { x: x - width - offsetX, y: y + offsetY, }; case Location.bottomright: return { x: x + offsetX, y: y + offsetY, }; default: return { x, y, }; } } // Calculates position of a list of annotations positionCallouts( annotations: Annotation[], isLeftToRight: boolean, xScale: ScaleLinear<number, number>, yScale: ScaleLinear<number, number>, _scale: number, fontSize: number, offset = 20, ): Callout[] { if (annotations.length === 0) { return []; } const alignment = isLeftToRight ? Location.topleft : Location.topright; const nodes = annotations.map(a => { const pos = a.pos ? a.pos : this.referenceSystem?.project(a.md!)!; return { title: a.title, label: a.label, color: a.color, pos: { x: pos?.[0]!, y: pos?.[1]! }, group: a.group, alignment, boundingBox: this.getAnnotationBoundingBox( a.title, a.label, pos, xScale, yScale, fontSize, ), dx: offset, dy: offset, }; }); const top = [nodes[nodes.length - 1]!]; const bottom: Callout[] = []; // Initial best effort this.chooseTopOrBottomPosition(nodes, bottom, top); // Adjust position for top set this.adjustTopPositions(top); // Adjust position for bottom set this.adjustBottomPositions(bottom); return nodes; } getAnnotationBoundingBox( title: string, label: string, pos: number[], xScale: ScaleLinear<number, number>, yScale: ScaleLinear<number, number>, height: number, ): { x: number; y: number; width: number; height: number } { const { ctx } = this; const ax1 = xScale(pos[0]!); const ay1 = yScale(pos[1]!); const labelWidth = ctx?.measureText(label).width ?? 0; const titleWidth = ctx?.measureText(title).width ?? 0; const width = Math.max(labelWidth, titleWidth); const bbox = { x: ax1, y: ay1, width, height: height * 2 + 4, }; return bbox; } chooseTopOrBottomPosition( nodes: Callout[], bottom: Callout[], top: Callout[], ): void { for (let i = nodes.length - 2; i >= 0; --i) { const node = nodes[i]!; const prevNode = top[0]!; const overlap = isOverlapping(node.boundingBox, prevNode.boundingBox); if (overlap) { node.alignment = node.alignment === Location.topleft ? Location.bottomright : Location.bottomleft; bottom.push(node); if (i > 0) { top.unshift(nodes[--i]!); } } else { top.unshift(node); } } } adjustTopPositions(top: Callout[]): void { for (let i = top.length - 2; i >= 0; --i) { const currentNode = top[i]!; for (let j = top.length - 1; j > i; --j) { const prevNode = top[j]!; const overlap = getOverlapOffset( currentNode.boundingBox, prevNode.boundingBox, ); if (overlap) { currentNode.dy += overlap.dy; currentNode.boundingBox.y -= overlap.dy; } } } } adjustBottomPositions(bottom: Callout[]): void { for (let i = bottom.length - 2; i >= 0; --i) { const currentNode = bottom[i]!; for (let j = bottom.length - 1; j > i; --j) { const prevNode = bottom[j]!; const overlap = getOverlapOffset( prevNode.boundingBox, currentNode.boundingBox, ); if (overlap) { currentNode.dy += overlap.dy; currentNode.boundingBox.y += overlap.dy; } } } } }