UNPKG

@geogirafe/lib-geoportal

Version:

GeoGirafe is a flexible application to build online geoportals.

1,331 lines (1,329 loc) 48.9 kB
import * as d3 from 'd3'; import { v4 as uuidv4 } from 'uuid'; export class Scatterplot { constructor(parent, margins, xy, rgb) { // CLASS VARIABLES - (STATIC PROPERTIES) // const – The declaration is of a compile time constant. // uniform - per-primitive parameters (constant during an entire draw call) ; // attribute - are per-vertex parameters (typically : positions, normals, colors, UVs, ...) ; // varying - per-fragment (or per-pixel) parameters : they vary from pixels to pixels. // Vertex shader Object.defineProperty(this, "vertexShaderSource", { enumerable: true, configurable: true, writable: true, value: `#version 300 es // VERTEX SHADER in vec2 vertex_position; in vec3 vertex_color; // in float vertex_classification; out lowp vec3 vert_color; // out lowp float vert_classification; uniform lowp float point_size; uniform vec2 offset; uniform vec2 range; uniform mat4 matrix; void main() { vec2 vertex_position_tr = vec2( 2.0 * ((vertex_position.x - offset.x) / range.x) - 1.0, 2.0 * ((vertex_position.y - offset.y) / range.y) - 1.0 ); gl_Position = matrix * vec4( vertex_position_tr, 0.0, 1.0 ); vert_color = vertex_color; gl_PointSize = point_size; // vertex_classification }` }); // Fragment shader Object.defineProperty(this, "fragmentShaderSource", { enumerable: true, configurable: true, writable: true, value: `#version 300 es // FRAGMENT SHADER precision lowp float; in vec3 vert_color; out vec4 color; void main() { color = vec4( vert_color, 1.0 ); }` }); // Default property values Object.defineProperty(this, "boundingBox", { enumerable: true, configurable: true, writable: true, value: { xMin: 0.0, xMax: 0.0, yMin: 0.0, yMax: 0.0 } }); Object.defineProperty(this, "pointSize", { enumerable: true, configurable: true, writable: true, value: 3 }); Object.defineProperty(this, "groupID", { enumerable: true, configurable: true, writable: true, value: uuidv4() }); Object.defineProperty(this, "measure", { enumerable: true, configurable: true, writable: true, value: false }); Object.defineProperty(this, "draw", { enumerable: true, configurable: true, writable: true, value: false }); Object.defineProperty(this, "backgroundColor", { enumerable: true, configurable: true, writable: true, value: [1.0, 1.0, 1.0, 1.0] }); Object.defineProperty(this, "xGridColor", { enumerable: true, configurable: true, writable: true, value: [0.0, 0.0, 0.0, 1.0] }); Object.defineProperty(this, "yGridColor", { enumerable: true, configurable: true, writable: true, value: [0.0, 0.0, 0.0, 1.0] }); Object.defineProperty(this, "xDomain", { enumerable: true, configurable: true, writable: true, value: [0, 100] }); Object.defineProperty(this, "yDomain", { enumerable: true, configurable: true, writable: true, value: [0, 100] }); Object.defineProperty(this, "verticalExaggeration", { enumerable: true, configurable: true, writable: true, value: 1.0 }); Object.defineProperty(this, "markers", { enumerable: true, configurable: true, writable: true, value: [] }); Object.defineProperty(this, "measurements", { enumerable: true, configurable: true, writable: true, value: [] }); Object.defineProperty(this, "canvasScalingFactor", { enumerable: true, configurable: true, writable: true, value: 3.0 }); Object.defineProperty(this, "cx0", { enumerable: true, configurable: true, writable: true, value: null }); Object.defineProperty(this, "cy0", { enumerable: true, configurable: true, writable: true, value: null }); Object.defineProperty(this, "x0", { enumerable: true, configurable: true, writable: true, value: 0.0 }); Object.defineProperty(this, "y0", { enumerable: true, configurable: true, writable: true, value: 0.0 }); // Property type declarations (without default values) Object.defineProperty(this, "parent", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "canvas", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "containerSVG", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "margins", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "xy", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "rgb", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "drag", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "measurementsSVG", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "pointsSVG", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "markerLabelBoxesSVG", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "markerLabelsSVG", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "measurementLabelsSVG", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "zoom", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "xAxis", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "yAxis", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "xGrid", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "yGrid", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "draggedElement", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "cursorMoveEvent", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "domainEvent", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "drawEvent", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "measureEvent", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "gl", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "shaderProgram", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "pointsize_uniform", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "offset_uniform", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "range_uniform", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "matrix_uniform", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "vertex_attrib", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "color_attrib", { enumerable: true, configurable: true, writable: true, value: void 0 }); this.parent = parent; this.margins = margins; this.xy = xy; this.rgb = rgb; this.initializeD3(); this.initializeWebGL(); this.initializeEvents(); } initializeD3() { // Add canvas this.canvas = d3.select(this.parent).append('canvas').attr('id', 'glcanvas'); // Add outer SVG this.containerSVG = d3 .select(this.parent) .append('svg') .attr('id', 'svg-outer') .attr('width', '100%') .attr('height', '100%') .attr('overflow', 'visible'); // Append SVG definitions this.containerSVG.append('defs'); // Definitions - Marker this.containerSVG .append('marker') .attr('id', 'dot') .attr('refX', 10) .attr('refY', 10) .attr('markerWidth', 15) .attr('markerHeight', 15) .append('circle') .attr('cx', 10) .attr('cy', 10) .attr('r', 1.5) .attr('fill', 'red'); // Definitions - Clipppath this.containerSVG .append('clipPath') .attr('id', 'clip') .append('rect') .attr('id', 'clipRegion') .attr('width', '100%') .attr('height', '100%') .attr('x', 0) .attr('y', 0) .attr('fill', 'red'); // X axis this.appendAxis('svg-x-axis'); // Y axis this.appendAxis('svg-y-axis'); // X grid this.containerSVG .append('g') .attr('id', 'svg-x-grid') .attr('class', 'x axis-grid') .attr('pointer-events', 'none') .attr('transform', '') .attr('stroke-width', 1) .attr('stroke-opacity', 0.1) .attr('stroke', '#ddeeff') .attr('fill', 'none') .attr('font-size', 12) .attr('font-family', 'sans-serif') .attr('text-anchor', 'middle'); // Y grid this.containerSVG .append('g') .attr('id', 'svg-y-grid') .attr('class', 'y axis-grid') .attr('pointer-events', 'none') .attr('transform', '') .attr('stroke-width', 1) .attr('stroke-opacity', 0.1) .attr('stroke', '#ddeeff') .attr('fill', 'none') .attr('font-size', 12) .attr('font-family', 'sans-serif') .attr('text-anchor', 'middle'); // Markers this.containerSVG.append('g').attr('id', 'svg-points').attr('clip-path', 'url(#clip)').attr('transform', ''); // Marker text label boxes this.containerSVG .append('g') .attr('id', 'svg-label-boxes') .attr('pointer-events', 'none') .attr('transform', '') .attr('fill', 'none') .attr('clip-path', 'url(#clip)') .attr('transform', ''); // Marker text label group this.containerSVG .append('g') .attr('id', 'svg-labels') .attr('font-family', 'sans-serif') .attr('clip-path', 'url(#clip)') .attr('transform', ''); // Measurements group this.containerSVG .append('g') .attr('id', 'svg-measurements') .attr('class', '') .attr('clip-path', 'url(#clip)') .attr('transform', ''); // Measurement text labels group this.containerSVG .append('g') .attr('id', 'svg-measurement-labels') .attr('pointer-events', 'none') .attr('font-family', 'sans-serif') .attr('clip-path', 'url(#clip)') .attr('transform', ''); // Initialize D3 axes and grids this.xAxis = d3.axisBottom(this.xScale); this.yAxis = d3.axisLeft(this.yScale); this.xGrid = d3.axisBottom(this.xScale); this.yGrid = d3.axisLeft(this.yScale); // Initialize drag this.drag = d3 .drag() .on('start', (e, d) => this.dragstarted(e, d)) .on('drag', (e, d) => this.dragged(e, d)) .on('end', (e, d) => this.dragended(e, d)); // Initialize SVG measurements this.measurementsSVG = d3.select(this.parent).select('#svg-measurements'); // Initialize SVG points this.pointsSVG = d3.select(this.parent).select('#svg-points'); this.pointsSVG .selectAll('circle') .data(this.markers.filter((x) => x.show)) .join('circle') .attr('id', function (_d, i) { return 'marker-' + i; }) .attr('class', 'point') .attr('fill', 'rgb(255,255,0)') .attr('fill-opacity', 0.75) .attr('stroke', 'red') .attr('stroke-width', 1.5) .attr('r', 5) .attr('cx', (d) => d.x) .attr('cy', (d) => d.y) // @ts-expect-error: D3 typing issue .call(this.drag); // Initialize SVG marker label boxes this.markerLabelBoxesSVG = d3.select(this.parent).select('#svg-label-boxes'); // Initialize SVG marker labels this.markerLabelsSVG = d3.select(this.parent).select('#svg-labels'); // Initialize SVG measurement labels this.measurementLabelsSVG = d3.select(this.parent).select('#svg-measurement-labels'); // Initialize canvas element this.updateCanvas(); // Initialize zoom this.zoom = d3 .zoom() .on('zoom', (e) => this.handleZoom(e)); // Update SVG body this.updateBody(); // Enable Zoom d3.select(this.parent).select('#svg-outer').call(this.zoom).on('dblclick.zoom', null); // disable zoom on double click // Update axes and grids this.updateXAxis(); this.updateYAxis(); this.updateXGrid(); this.updateYGrid(); } initializeWebGL() { // Set WEBGL context this.gl = this.canvas.node().getContext('webgl2'); // Initialize WEBGL shader this.shaderProgram = this.GLProgram(this.gl, this.vertexShaderSource, this.fragmentShaderSource); this.gl.useProgram(this.shaderProgram); this.vertex_attrib = this.gl.getAttribLocation(this.shaderProgram, 'vertex_position'); this.color_attrib = this.gl.getAttribLocation(this.shaderProgram, 'vertex_color'); this.pointsize_uniform = this.gl.getUniformLocation(this.shaderProgram, 'point_size'); this.offset_uniform = this.gl.getUniformLocation(this.shaderProgram, 'offset'); this.range_uniform = this.gl.getUniformLocation(this.shaderProgram, 'range'); this.matrix_uniform = this.gl.getUniformLocation(this.shaderProgram, 'matrix'); // Enable WEBGL attributes this.gl.enableVertexAttribArray(this.vertex_attrib); this.gl.enableVertexAttribArray(this.color_attrib); this.initializeGL(); this.updateTransform(); } initializeEvents() { // Cursor move event this.cursorMoveEvent = new CustomEvent('cursormove', { detail: { domainCoords: [NaN, NaN], rangeCoords: [NaN, NaN] }, bubbles: true, cancelable: true, composed: false }); // Domain change event this.domainEvent = new CustomEvent('changedomain', { detail: { xDomain: this.xDomain, yDomain: this.yDomain }, bubbles: true, cancelable: true, composed: false }); // Annotation (marker) change event this.drawEvent = new CustomEvent('changemarkers', { detail: { markers: this.markers }, bubbles: true, cancelable: true, composed: false }); // Measurement change event this.measureEvent = new CustomEvent('changemeasurements', { detail: { measurements: this.measurements }, bubbles: true, cancelable: true, composed: false }); // Resize observer const resizeObs = new ResizeObserver(() => { this.updatePlot(); d3.select(this.parent) .select('#svg-outer') .transition() .duration(100) // @ts-expect-error: D3 typing issue .call(this.zoom.transform, this.zoomTransform); }); resizeObs.observe(this.parent); // Cursor move event listener d3.select(this.parent) .select('#svg-outer') .on('mousemove', (event) => { // Range space (SVG space) coordinates const rangeCoords = d3.pointer(event); // Domain space (data space) coordinates const domainCoords = [this.xScale.invert(rangeCoords[0]), this.yScale.invert(rangeCoords[1])]; // Dispatch draw event this.cursorMoveEvent.detail.rangeCoords = rangeCoords; this.cursorMoveEvent.detail.domainCoords = domainCoords; this.parent.dispatchEvent(this.cursorMoveEvent); }); // Click event listener d3.select(this.parent) .select('#svg-outer') .on('click', (event) => { // Number of clicks const nclicks = event.detail; // Range space (SVG space) coordinates const rangeCoords = d3.pointer(event); // Domain space (data space) coordinates const domainCoords = [this.xScale.invert(rangeCoords[0]), this.yScale.invert(rangeCoords[1])]; if (this.draw) { const uuid = uuidv4(); // add Marker this.markers.push({ id: uuid, label: this.generateId(this.markers), x: domainCoords[0], y: domainCoords[1], u: rangeCoords[0], v: rangeCoords[1], xm: null, ym: null, zm: null, colour: '#FFFFFF', opacity: 1.0, feature: 'marker', group: null, show: true, update: true }); // Append SVG marker this.appendMarker(uuid, rangeCoords); // Dispatch draw event this.drawEvent.detail.markers = this.markers; this.parent.dispatchEvent(this.drawEvent); } if (this.measure) { // Start a new measurement when the user double clicks if (nclicks === 2) { // Append an SVG path to the plot this.groupID = uuidv4(); this.addMeasurement(this.groupID); return; } // Start a new measurement if the are no existing measurements if (this.measurementPoints.length === 0) { // Append an SVG path to the plot this.groupID = uuidv4(); this.addMeasurement(this.groupID); } // Add a new marker to the array of markers const uuid = uuidv4(); this.markers.push({ id: uuid, label: this.generateId(this.markers), x: domainCoords[0], y: domainCoords[1], u: rangeCoords[0], v: rangeCoords[1], xm: null, ym: null, zm: null, colour: '#FFFFFF', opacity: 1.0, feature: 'measurement', group: `${this.groupID}`, show: true, update: true }); // Append an SVG marker to the plot this.appendMarker(uuid, rangeCoords); // Update the array of measurements this.updateMeasurements(); // Update current SVG measurement path this.measurementsSVG .select(`#path-${this.groupID}`) .datum(this.markers.filter((x) => x.group === this.groupID)) .attr('d', d3 .line() .x((d) => this.xScale(d.x)) .y((d) => this.yScale(d.y))); // Dispatch draw event this.drawEvent.detail.markers = this.markers; this.parent.dispatchEvent(this.drawEvent); // Dispatch measure event this.measureEvent.detail.measurements = this.measurements; this.parent.dispatchEvent(this.measureEvent); } this.updateLabels(); }); } get measurementPoints() { return this.markers.filter((x) => x.feature === 'measurement'); } get height() { return this.parent.clientHeight; } get width() { return this.parent.clientWidth; } get innerWidth() { return this.width - this.margins.left - this.margins.right; } get innerHeight() { return this.height - this.margins.top - this.margins.bottom; } get xRange() { return [0, this.innerWidth]; } get yRange() { return [this.innerHeight, 0]; } get domainWidth() { return this.xDomain[1] - this.xDomain[0]; } get domainHeight() { return this.verticalExaggeration * (this.yDomain[1] - this.yDomain[0]); } get domainAspectRatio() { return this.domainWidth / this.domainHeight; } get rangeAspectRatio() { return this.innerWidth / this.innerHeight; } get xDomainAdjusted() { if (this.domainAspectRatio <= this.rangeAspectRatio) { return [this.boundingBox.xMin, this.rangeAspectRatio * this.domainHeight + this.boundingBox.xMin]; } else { return this.xDomain; } } get yDomainAdjusted() { if (this.domainAspectRatio > this.rangeAspectRatio) { return [ this.boundingBox.yMin, this.boundingBox.yMin + this.domainWidth / (this.verticalExaggeration * this.rangeAspectRatio) ]; } else { return this.yDomain; } } get xScale() { return this.zoomTransform.rescaleX(d3.scaleLinear(this.xDomainAdjusted, this.xRange)); } get yScale() { return this.zoomTransform.rescaleY(d3.scaleLinear(this.yDomainAdjusted, this.yRange)); } get nPoints() { return this.xy.length / 2; } get zoomTransform() { const selection = d3.select(this.parent).select('#svg-outer'); const node = selection.node(); if (node) { return d3.zoomTransform(node); } else { return d3.zoomIdentity; } } updateBody() { d3.select(this.parent) .select('#svg-outer') .attr('width', this.innerWidth) .attr('height', this.innerHeight) .attr('viewBox', [0, 0, this.innerWidth, this.innerHeight]) .style('top', `${this.margins.top}px`) .style('left', `${this.margins.left}px`); } updateCanvas() { d3.select(this.parent) .select('#glcanvas') .attr('width', this.canvasScalingFactor * this.innerWidth) .attr('height', this.canvasScalingFactor * this.innerHeight) .style('top', `${this.margins.top}px`) .style('left', `${this.margins.left}px`) .style('width', `${this.innerWidth}px`) .style('height', `${this.innerHeight}px`); } appendAxis(id) { this.containerSVG .append('g') .attr('id', id) .attr('class', 'svg-axis') .attr('pointer-events', 'none') .attr('transform', '') .attr('fill', 'none') .attr('font-size', 12) .attr('font-family', 'sans-serif') .attr('text-anchor', 'middle') .attr('style', 'color: var(--text-color)'); } updateXAxis() { this.xAxis = d3.axisBottom(this.xScale); d3.select(this.parent) .select('#svg-x-axis') .attr('transform', `translate(${0}, ${this.innerHeight})`) .call(this.xAxis); } updateYAxis() { this.yAxis = d3.axisLeft(this.yScale); d3.select(this.parent) .select('#svg-y-axis') .attr('transform', `translate(${0}, ${0})`) .call(this.yAxis); } updateXGrid() { this.xGrid = d3 .axisBottom(this.xScale) .tickSize(-this.innerHeight) .tickFormat(() => ''); // .ticks(width / 80) d3.select(this.parent) .select('#svg-x-grid') .attr('transform', `translate(${0}, ${this.innerHeight})`) .call(this.xGrid); } updateYGrid() { this.yGrid = d3 .axisLeft(this.yScale) .tickSize(-this.innerWidth) .tickFormat(() => ''); // .ticks(height / 60) d3.select(this.parent) .select('#svg-y-grid') .attr('transform', `translate(${0}, ${0})`) .call(this.yGrid); } updateBoundingBox() { const x = new Float32Array(this.nPoints); const y = new Float32Array(this.nPoints); for (let i = 0; i < this.nPoints; i++) { x[i] = this.xy[2 * i + 0]; y[i] = this.xy[2 * i + 1]; } this.boundingBox = { xMin: x.reduce((acc, current) => Math.min(acc, current)), xMax: x.reduce((acc, current) => Math.max(acc, current)), yMin: y.reduce((acc, current) => Math.min(acc, current)), yMax: y.reduce((acc, current) => Math.max(acc, current)) }; } updatePoints(xy) { this.xy = xy; this.updateBoundingBox(); this.xDomain = [this.boundingBox.xMin, this.boundingBox.xMax]; this.yDomain = [this.boundingBox.yMin, this.boundingBox.yMax]; } updateColors(rgb) { this.rgb = rgb; } updatePaths() { const paths = this.measurementsSVG.selectAll('path'); paths.nodes().forEach((pathNode) => { const path = d3.select(pathNode); const pathNodes = this.markers.filter((x) => `path-${x.group}` === path.attr('id')); path.datum(pathNodes).attr('d', d3 .line() .x((d) => this.xScale(d.x)) .y((d) => this.yScale(d.y))); }); } updateMarkers() { this.pointsSVG .selectAll('circle') .data(this.markers.filter((x) => x.show)) .join('circle') .attr('id', (d) => `marker-${d.id}`) .attr('class', 'point') .attr('fill', 'rgb(255,255,0)') .attr('fill-opacity', 0.75) .attr('stroke', 'red') .attr('stroke-width', 1.5) .attr('r', 5) .attr('cx', (d) => this.xScale(d.x)) .attr('cy', (d) => this.yScale(d.y)) // @ts-expect-error: D3 typing issue .call(this.drag); } appendMarker(id, coords) { this.pointsSVG .selectAll('circle') .data(this.markers.filter((x) => x.show)) .enter() .append('circle') .attr('id', id) .attr('class', 'point') .attr('fill', 'rgb(255,255,0)') .attr('fill-opacity', 0.75) .attr('stroke', 'red') .attr('stroke-width', 1.5) .attr('r', 5) .attr('cx', coords[0]) .attr('cy', coords[1]) .attr('cursor', 'grab') // @ts-expect-error: D3 typing issue .call(this.drag); } updateMeasurements() { // Clear all elements in measurements array this.measurements.splice(0, this.measurements.length); // Update measurements for (let i = 0; i < this.markers.length - 1; i++) { if (this.markers[i].feature === 'measurement') { if (this.markers[i].group === this.markers[i + 1].group) { const dx = this.markers[i].x - this.markers[i + 1].x; const dy = this.markers[i].y - this.markers[i + 1].y; // this.measurements[i].cl = this.measurements[i - 1].cl + this.measurements[i].dl // cumulative length this.measurements.push({ id: this.markers[i].group, label: this.generateId(this.markers), x: (this.markers[i].x + this.markers[i + 1].x) / 2, y: (this.markers[i].y + this.markers[i + 1].y) / 2, u: (this.markers[i].u + this.markers[i + 1].u) / 2, v: (this.markers[i].v + this.markers[i + 1].v) / 2, from: this.markers[i].id, to: this.markers[i + 1].id, distance: Math.sqrt(dx * dx + dy * dy), slope: dy / dx, text: null }); } } } // Update SVG measurement paths this.updatePaths(); } updateLabels() { // Update SVG marker text labels this.markerLabelsSVG .selectAll('text') .data(this.markers.filter((x) => x.show)) .join('text') .attr('id', (d) => `label-${d.id}`) .attr('x', (d) => this.xScale(d.x)) .attr('y', (d) => this.yScale(d.y)) .text((d) => `${d.label}`) .attr('dy', '-20px') .attr('pointer-events', 'none') .attr('text-anchor', 'middle') .attr('font-family', 'sans-serif') .attr('font-size', 16) .attr('fill', 'white'); this.markerLabelBoxesSVG .selectAll('rect') .data(this.markers.filter((x) => x.show)) .join('rect') .attr('id', (d) => `label-box-${d.id}`) .attr('x', (d) => this.xScale(d.x) - 13) .attr('y', (d) => this.yScale(d.y) - 38) .attr('pointer-events', 'none') .attr('width', 26) .attr('height', 26) .attr('fill', 'black') .attr('stroke', 'white') .attr('stroke-width', '1px'); this.measurementLabelsSVG .selectAll('text') .data(this.measurements) .join('text') .attr('id', (d) => `mlabel-${d.id}`) .attr('x', (d) => this.xScale(d.x)) .attr('y', (d) => this.yScale(d.y)) .text((d) => `${d.distance.toFixed(2)} | ${(100 * d.slope).toFixed(2)}%`) .attr('dy', '0px') .attr('pointer-events', 'none') .attr('text-anchor', 'middle') .attr('font-family', 'sans-serif') .attr('font-size', 18) .attr('font-weight', 'bolder') .attr('fill', '#FF4500'); } setBackgroundColor(val) { const color = d3.rgb(val); this.backgroundColor = [color.r / 255, color.g / 255, color.b / 255, color.opacity]; this.initializeGL(); this.updateTransform(); } handleZoom(_e) { this.updatePlot(); this.updateTransform(); // Dispatch domain change (Zoom/Pan) event this.domainEvent.detail.xDomain = this.xScale.domain(); this.domainEvent.detail.yDomain = this.yScale.domain(); this.parent.dispatchEvent(this.domainEvent); } setZoom(tf) { const zoom = new d3.ZoomTransform(tf.k, tf.tx, tf.ty); d3.select(this.parent) .select('#svg-outer') .transition() .duration(100) // @ts-expect-error: D3 typing issue .call(this.zoom.transform, zoom); } resetZoom() { d3.select(this.parent) .select('#svg-outer') .transition() .duration(100) // @ts-expect-error: D3 typing issue .call(this.zoom.transform, d3.zoomIdentity); } getDraggedElement(event) { const path = event.composedPath(); // Return the first element in the path that is an instance of SVGCircleElement return path.find((el) => el instanceof SVGCircleElement); } updateDraggedElements(e, _d) { const cx0 = this.xScale(this.x0); const cy0 = this.yScale(this.y0); _d.u = cx0 + e.x - this.x0; _d.v = cy0 + e.y - this.y0; _d.x = this.xScale.invert(_d.u); _d.y = this.yScale.invert(_d.v); _d.update = true; this.updateMeasurements(); this.updateLabels(); // Update dragged SVG element position d3.select(this.draggedElement).attr('cx', _d.u).attr('cy', _d.v); // Render SVG measurement path this.measurementsSVG // .selectAll("path") .select(`#path-${_d.group}`) .attr('stroke', 'rgba(255,140,0,0.5)') .attr('d', // @ts-expect-error: D3 typing issue d3 .line() .x((d) => this.xScale(d.x)) .y((d) => this.yScale(d.y))); // Dispatch draw event this.drawEvent.detail.markers = this.markers; this.parent.dispatchEvent(this.drawEvent); } // Drag start callback dragstarted(e, _d) { this.draggedElement = this.getDraggedElement(e.sourceEvent); // Access the component context this.cx0 = this.xScale(_d.x); this.cy0 = this.yScale(_d.y); this.x0 = _d.x; this.y0 = _d.y; // Change the mouse cursor to grabbing d3.select(this.draggedElement).attr('cursor', 'grabbing'); } // Drag ongoing callback dragged(e, _d) { this.updateDraggedElements(e, _d); d3.select(this.draggedElement).attr('cursor', 'grabbing'); } // Drag end callback dragended(e, _d) { this.updateDraggedElements(e, _d); d3.select(this.draggedElement).attr('cursor', 'grab'); } updatePointSize(val) { if (typeof val === 'number') { this.pointSize = val; this.updatePlot(); this.updateTransform(); } } updateVerticalExaggeration(val) { if (typeof val === 'number') { this.verticalExaggeration = val; this.updatePlot(); this.updateTransform(); } } toggleAnnotation(val) { this.draw = val; this.measure = false; d3.select(this.parent) .select('#svg-outer') .attr('cursor', this.draw ? 'crosshair' : 'default'); } deleteAllMarkers() { if (this.markers.length === 0) { return; } this.markers.splice(0, this.markers.length); this.pointsSVG.selectAll('*').remove(); this.markerLabelsSVG.selectAll('*').remove(); this.markerLabelBoxesSVG.selectAll('*').remove(); // Delete SVG paths this.measurementsSVG.selectAll('*').remove(); // Delete SVG labels this.measurementLabelsSVG.selectAll('*').remove(); this.updateMeasurements(); // Dispatch draw event this.drawEvent.detail.markers = this.markers; this.parent.dispatchEvent(this.drawEvent); } deleteMarker(id) { const target = this.markers.find((obj) => obj.id === id); if (target) { // Delete marker data const index = this.markers.findIndex((el) => el.id === id); if (index !== -1) { this.markers.splice(index, 1); } // Delete SVG element this.pointsSVG.select(`#marker-${id}`).remove(); this.updateMeasurements(); this.updateLabels(); // Update SVG path in measurement group this.measurementsSVG .select(`#path-${target.group}`) .datum(this.markers.filter((x) => x.group === target.group)) .attr('d', d3 .line() .x((d) => this.xScale(d.x)) .y((d) => this.yScale(d.y))); } // Delete marker label by ID this.markerLabelsSVG.select(`#label-${id}`).remove(); // Delete marker label box by ID this.markerLabelBoxesSVG.select(`#label-box-${id}`).remove(); // Dispatch draw event this.drawEvent.detail.markers = this.markers; this.parent.dispatchEvent(this.drawEvent); } toggleMeasurement(val) { this.measure = val; this.draw = false; d3.select(this.parent) .select('#svg-outer') .attr('cursor', this.measure ? 'crosshair' : 'default'); } addMeasurement(id) { this.measurementsSVG .append('path') .attr('id', `path-${id}`) .attr('class', 'line') .attr('clip-path', 'url(#clip)') .attr('pointer-events', 'none') .attr('fill', 'none') .attr('stroke-width', 2) .attr('stroke', 'rgba(255,140,0,0.5)') .attr('marker-start', 'url(#dot)') .attr('marker-mid', 'url(#dot)') .attr('marker-end', 'url(#dot)') .datum(this.markers.filter((x) => x.group === id)) .attr('d', d3 .line() .x((d) => this.xScale(d.x)) .y((d) => this.yScale(d.y))); } deleteAllMeasurements() { if (this.measurements.length === 0) { return; } // Clear all elements in array, but keep reference this.measurements.splice(0, this.measurements.length); // Delete SVG measurement paths this.measurementsSVG.selectAll('*').remove(); // Delete SVG measurement labels this.measurementLabelsSVG.selectAll('*').remove(); // Reset measurement group ID this.groupID = uuidv4(); // Remove measurements from array of markers for (let i = this.markers.length - 1; i >= 0; i--) { if (this.markers[i].feature === 'measurement') { this.markers.splice(i, 1); } } this.updateMarkers(); this.updateLabels(); // Dispatch draw event this.drawEvent.detail.markers = this.markers; this.parent.dispatchEvent(this.drawEvent); // Dispatch measure event this.measureEvent.detail.measurements = this.measurements; this.parent.dispatchEvent(this.measureEvent); } updatePlot() { // Canvas this.updateCanvas(); // Body this.updateBody(); // X-axis this.updateXAxis(); // Y-axis this.updateYAxis(); // X-grid this.updateXGrid(); // Y-grid this.updateYGrid(); // Markers this.updateMarkers(); // Measurements this.updateMeasurements(); // Labels this.updateLabels(); } resetPlot() { // Clear point coordinates and colors this.xy = new Float32Array(0); this.rgb = new Uint8Array(0); // Clear GL points this.clearGLPoints(); // Reset zoom this.resetZoom(); this.markers.forEach((_el, _i) => { _el.show = false; }); // DISPATCH DRAW EVENT this.drawEvent.detail.markers = this.markers; this.parent.dispatchEvent(this.drawEvent); } generateId(markers) { let id = 1; const existingIds = new Set(markers.map((marker) => parseInt(marker.label))); while (existingIds.has(id)) { id++; } return id.toString(); } clearGLPoints() { this.gl.clear(this.gl.COLOR_BUFFER_BIT); } initializeGL() { this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height); this.gl.clearColor(this.backgroundColor[0], this.backgroundColor[1], this.backgroundColor[2], this.backgroundColor[3]); this.gl.clear(this.gl.COLOR_BUFFER_BIT); // Position (UV) buffer const vertex_buffer = this.gl.createBuffer(); this.gl.bindBuffer(this.gl.ARRAY_BUFFER, vertex_buffer); this.gl.bufferData(this.gl.ARRAY_BUFFER, this.xy, this.gl.STATIC_DRAW); // Vertex attribute pointer (index, size, type, normalized, stride in bytes, offset in bytes) this.gl.vertexAttribPointer(this.vertex_attrib, 2, this.gl.FLOAT, false, 0, 0); // Color (RGB) buffer const color_buffer = this.gl.createBuffer(); this.gl.bindBuffer(this.gl.ARRAY_BUFFER, color_buffer); this.gl.bufferData(this.gl.ARRAY_BUFFER, this.rgb, this.gl.STATIC_DRAW); // Vertex attribute pointer (index, size, type, normalized, stride in bytes, offset in bytes) this.gl.vertexAttribPointer(this.color_attrib, 3, this.gl.UNSIGNED_BYTE, true, 0, 0); } // Multiply two 4x4 matrices multiplyMatrix(a, b) { const out = new Float32Array(16); for (let i = 0; i < 4; ++i) { for (let j = 0; j < 4; ++j) { out[i * 4 + j] = a[i * 4 + 0] * b[0 * 4 + j] + a[i * 4 + 1] * b[1 * 4 + j] + a[i * 4 + 2] * b[2 * 4 + j] + a[i * 4 + 3] * b[3 * 4 + j]; } } return out; } createOrthographic(left, right, bottom, top, near, far) { const lr = 1 / (left - right); const bt = 1 / (bottom - top); const nf = 1 / (near - far); const out = new Float32Array(16); out[0] = -2 * lr; out[5] = -2 * bt; out[10] = 2 * nf; out[12] = (left + right) * lr; out[13] = (top + bottom) * bt; out[14] = (far + near) * nf; out[15] = 1; return out; } createTranslation(tx, ty, tz) { const out = new Float32Array(16); out[0] = 1; out[5] = 1; out[10] = 1; out[12] = tx; out[13] = ty; out[14] = tz; out[15] = 1; return out; } createScale(sx, sy, sz) { const out = new Float32Array(16); out[0] = sx; out[5] = sy; out[10] = sz; out[15] = 1; return out; } updateTransform() { const k = this.zoomTransform.k; const tx = this.zoomTransform.x; const ty = this.zoomTransform.y; const sfac = 1 / this.canvasScalingFactor; this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height); // Create an orthographic matrix let matrix = this.createOrthographic(0, sfac * this.gl.canvas.width, sfac * this.gl.canvas.height, 0, -1, 1); // Apply translation matrix const translationMatrix = this.createTranslation(tx, ty, 0); matrix = this.multiplyMatrix(translationMatrix, matrix); // Apply scaling (zoom) matrix const scaleMatrix = this.createScale(k, k, 1); matrix = this.multiplyMatrix(scaleMatrix, matrix); // Translate the unit quad to the center const centerTranslationMatrix = this.createTranslation((sfac * this.gl.canvas.width) / 2, (sfac * this.gl.canvas.height) / 2, 0); matrix = this.multiplyMatrix(centerTranslationMatrix, matrix); // Scale the unit quad to be half the size of the canvas const halfScaleMatrix = this.createScale((sfac * this.gl.canvas.width) / 2, (sfac * this.gl.canvas.height) / 2, 1); matrix = this.multiplyMatrix(halfScaleMatrix, matrix); // Flip the Y axis (for WebGL default coordinate system) const flipYMatrix = this.createScale(1, -1, 1); matrix = this.multiplyMatrix(flipYMatrix, matrix); this.gl.uniformMatrix4fv(this.matrix_uniform, false, matrix); // NOTE: The range and offset do not vary for webgl, perhaps apply to data directly instead of recomputing // in shader at every view transform const offset = new Float32Array([this.xDomainAdjusted[0], this.yDomainAdjusted[0]]); const range = new Float32Array([ this.xDomainAdjusted[1] - this.xDomainAdjusted[0], this.yDomainAdjusted[1] - this.yDomainAdjusted[0] ]); this.gl.uniform2fv(this.offset_uniform, offset); this.gl.uniform2fv(this.range_uniform, range); this.gl.uniform1f(this.pointsize_uniform, this.pointSize); this.gl.clear(this.gl.COLOR_BUFFER_BIT); this.gl.drawArrays(this.gl.POINTS, 0, this.nPoints); } GLShader(gl, type, code) { const shader = gl.createShader(type); gl.shaderSource(shader, code); gl.compileShader(shader); return shader; } GLProgram(gl, vertex_source, fragment_source) { const shader_program = gl.createProgram(); const vertex_shader = this.GLShader(gl, gl.VERTEX_SHADER, vertex_source); const fragment_shader = this.GLShader(gl, gl.FRAGMENT_SHADER, fragment_source); if (!shader_program) { throw new Error('GLProgram: Invalid shader program'); } if (!vertex_shader) { throw new Error('GLProgram: Invalid vertex shader'); } if (!fragment_shader) { throw new Error('GLProgram: Invalid fragment shader'); } gl.attachShader(shader_program, vertex_shader); gl.attachShader(shader_program, fragment_shader); gl.linkProgram(shader_program); gl.deleteShader(vertex_shader); gl.deleteShader(fragment_shader); return shader_program; } }