diff --git a/examples/storybook/stories/shared/mock-data.js b/examples/storybook/stories/shared/mock-data.js index 323db65..36ee486 100644 --- a/examples/storybook/stories/shared/mock-data.js +++ b/examples/storybook/stories/shared/mock-data.js @@ -227,7 +227,7 @@ export const ex7_shortName = async() => { }; export const exampleDistributionData = () => { - const components = ['carbonate', 'shale', 'sand']; + const components = ['carbonate', 'sand', 'shale']; const data = []; for (let depth = 200; depth <= 1000; depth += 10) { diff --git a/examples/storybook/stories/shared/tracks.js b/examples/storybook/stories/shared/tracks.js index 4a2bd2e..0c9a022 100644 --- a/examples/storybook/stories/shared/tracks.js +++ b/examples/storybook/stories/shared/tracks.js @@ -17,9 +17,9 @@ import { } from './mock-data'; const distributionComponents = { - 'carbonate': { color: 'red' }, - 'sand': { color: 'green' }, - 'shale': { color: 'blue' }, + 'carbonate': { color: 'LightSalmon' }, + 'sand': { color: 'Moccasin' }, + 'shale': { color: 'LightGray' }, }; export default (delayLoading = false) => { @@ -138,7 +138,7 @@ export default (delayLoading = false) => { data: exampleDistributionData, legendConfig: distributionLegendConfig(distributionComponents), components: distributionComponents, - discreteHeight: 5, + interpolate: true, }), ]; diff --git a/package-lock.json b/package-lock.json index 4330046..d493106 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@equinor/videx-wellog", - "version": "0.10.0", + "version": "0.10.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@equinor/videx-wellog", - "version": "0.10.0", + "version": "0.10.1", "license": "MIT", "dependencies": { "@equinor/videx-math": "^1.1.0", diff --git a/package.json b/package.json index c5decb1..b5c13f8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": false, "name": "@equinor/videx-wellog", - "version": "0.10.0", + "version": "0.10.1", "license": "MIT", "description": "Visualisation components for wellbore log data", "repository": "https://github.com/equinor/videx-wellog", diff --git a/src/tracks/distribution/distribution-track.ts b/src/tracks/distribution/distribution-track.ts index 057efc7..8133893 100644 --- a/src/tracks/distribution/distribution-track.ts +++ b/src/tracks/distribution/distribution-track.ts @@ -1,18 +1,45 @@ -import { scaleLinear, ScaleLinear } from 'd3-scale'; -import { select } from 'd3-selection'; -import SvgTrack from '../svg-track'; +import { ScaleLinear, scaleLinear } from 'd3-scale'; +import CanvasTrack from '../canvas-track'; import { - CompositionEntry, DistributionData, DistributionTrackOptions, } from './interfaces'; import { OnMountEvent, OnRescaleEvent, OnUpdateEvent } from '../interfaces'; -import { setAttrs } from '../../utils'; + +interface DistributionPolygon { + /** Color of the element. */ + color: string; + /** Points array to store depths and values. */ + points: { depth: number, value: number }[]; +} + +const defaultOptions: DistributionTrackOptions = { + discreteHeight: 10, + interpolate: false, +}; + +/** Helper function for drawing filled rectangle vertically or horizontally. */ +const fillRect = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, horizontal: boolean) => { + ctx.fillRect( + horizontal ? y : x, + horizontal ? x : y, + horizontal ? height : width, + horizontal ? width : height, + ); +}; /** Track for visualising distribution of data. */ -export class DistributionTrack extends SvgTrack { +export class DistributionTrack extends CanvasTrack { xscale: ScaleLinear; - discreteHeight: number; + + constructor(id: string | number, options: DistributionTrackOptions = {}) { + super(id, { + ...defaultOptions, + ...options, + }); + + this.xscale = scaleLinear().domain([0, 1]); + } /** Override of onMount from base class. */ onMount(event: OnMountEvent) { @@ -23,9 +50,6 @@ export class DistributionTrack extends SvgTrack { loader, } = this; - this.xscale = scaleLinear().domain([0, 1]); - this.discreteHeight = options.discreteHeight || 10; - if (options.data) { const showLoader = options.showLoader ?? Boolean(loader); @@ -54,23 +78,40 @@ export class DistributionTrack extends SvgTrack { this.plot(); } - plot() { + private plot() { const { - plotGroup: g, - scale: yscale, - xscale, + ctx, data, options, - discreteHeight, } = this; - if (!g) return; + if (!ctx) return; + + // Clear canvas + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + + // Return early if 'data' is undefined or empty + if (!data?.length) return; - // Clear visuals if 'data' is undefined or empty - if (!data?.length) { - g.selectAll('g.area').remove(); - return; + if (options.interpolate) { + this.plotInterpolated(); + } else { + this.plotDiscrete(); } + } + + private plotDiscrete() { + const { + ctx, + scale: yscale, + xscale, + data, + options, + } = this; + + const { + discreteHeight, + } = options; // Get the current visible domain const [min, max] = yscale.domain(); @@ -79,69 +120,124 @@ export class DistributionTrack extends SvgTrack { const visibleData = data.filter((d: DistributionData) => d.depth + discreteHeight >= min && d.depth - discreteHeight <= max); // Transform depth - const transformedData: DistributionData = visibleData.map((d: DistributionData) => ({ + const transformedData: DistributionData[] = visibleData.map((d: DistributionData) => ({ depth: yscale(d.depth), composition: d.composition, })); - const selection = g.selectAll('g.area').data(transformedData, (d: DistributionData) => d.depth); + transformedData.forEach(d => { + let cumulativeWidth = 0; + d.composition.forEach(({ key, value }) => { + const width = xscale(value / 100); // Scale the width using xscale + const color = options.components[key]?.color || 'black'; + ctx.fillStyle = color; + fillRect( + ctx, + cumulativeWidth, + d.depth - discreteHeight / 2, + width, + discreteHeight, + options.horizontal, + ); + cumulativeWidth += width; + }); + }); + } - const horizontalTransform = (d: DistributionData) => `translate(${d.depth - discreteHeight / 2}, 0)`; - const verticalTransform = (d: DistributionData) => `translate(0, ${d.depth - discreteHeight / 2})`; - const transform = options.horizontal ? horizontalTransform : verticalTransform; + private plotInterpolated() { + const { + ctx, + scale: yscale, + xscale, + data, + options, + } = this; - selection.attr('transform', transform); + // Get the current visible domain + const [min, max] = yscale.domain(); - const getRectGeom = (x0: number, x1: number, color: string) => (options.horizontal - // Horizontal Rectangle - ? { - x: 0, - y: xscale(x0), - width: discreteHeight, - height: xscale(x1), - fill: color, - } - // Vertical Rectangle - : { - x: xscale(x0), - y: 0, - width: xscale(x1), - height: discreteHeight, - fill: color, - } - ); + // Filter data based on the visible domain and adjacent points + const visibleData = data.filter((d: DistributionData, i: number) => { + const prevDepth = data[i - 1]?.depth || -Infinity; + const nextDepth = data[i + 1]?.depth || Infinity; + return (d.depth >= min && d.depth <= max) || nextDepth > min || prevDepth < max; + }); - const updateGroup = (group: any, composition: CompositionEntry[]) => { - let cumulativeWidth = 0; + // Initiate distribution polygons + const polygonData: { [key: string]: DistributionPolygon } = {}; + Object.entries(options.components).forEach(([key, component]) => { + polygonData[key] = { + color: component.color, + points: [], + }; + }); - composition.forEach(({ key, value }) => { - const width = (value / 100); - const color = options.components[key]?.color; - const rectGeom = getRectGeom(cumulativeWidth, cumulativeWidth + width, color || 'black'); - cumulativeWidth += width; - group.append('rect').call(setAttrs, rectGeom); + // Populate depths and values for each component + visibleData.forEach((d: DistributionData) => { + const depth = yscale(d.depth); + let cumulativeWidth = 0; + Object.keys(polygonData).forEach(key => { + const comp = d.composition.find(c => c.key === key); + if (comp) { + cumulativeWidth += xscale(comp.value / 100); + } + polygonData[key].points.push({ + depth, + value: cumulativeWidth, + }); }); - }; - - // Update existing areas - // eslint-disable-next-line func-names - selection.each(function (d: DistributionData) { - const group = select(this); - group.selectAll('rect').remove(); // Clear previous rects - updateGroup(group, d.composition); }); - // Create new areas - const newAreas = selection.enter().append('g') - .classed('area', true) - .attr('transform', transform); + const createPolygon = (points: { depth: number, value: number }[]): [number, number][] => { + const polygonPoints: [number, number][] = []; - // eslint-disable-next-line func-names - newAreas.each(function (d: DistributionData) { - const group = select(this); - updateGroup(group, d.composition); - }); + const addPoint = (x: number, y: number) => polygonPoints.push(options.horizontal ? [y, x] : [x, y]); + + // Add first point + const firstPoint = points[0]; + addPoint(0, firstPoint.depth); + + // Add the points of the polygon + points.forEach(({ depth, value }) => addPoint(value, depth)); - selection.exit().remove(); + // Add last point + const lastPoint = points[points.length - 1]; + addPoint(0, lastPoint.depth); + + return polygonPoints; + }; + + // Create and draw polygons + // Drawn in reverse order to make sure the wider polygons are in the back. + Object.values(polygonData).reverse().forEach((polygon, i) => { + // Draw simple rect for background polygon + if (i === 0) { + const firstPoint = polygon.points[0]; + const lastPoint = polygon.points[polygon.points.length - 1]; + ctx.fillStyle = polygon.color; + fillRect( + ctx, + 0, + firstPoint.depth, + xscale(1), + lastPoint.depth - firstPoint.depth, + options.horizontal, + ); + return; + } + + const polygonPoints = createPolygon(polygon.points); + ctx.fillStyle = polygon.color; + ctx.beginPath(); + polygonPoints.forEach(([x, y], index) => { + if (index === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + }); + ctx.closePath(); + ctx.fill(); + }); } } diff --git a/src/tracks/distribution/interfaces.ts b/src/tracks/distribution/interfaces.ts index dc1aa61..ae343b3 100644 --- a/src/tracks/distribution/interfaces.ts +++ b/src/tracks/distribution/interfaces.ts @@ -15,7 +15,10 @@ export interface DistributionTrackOptions extends TrackOptions { horizontal?: boolean, /** The height to use for discrete values. */ - discreteHeight: number, + discreteHeight?: number, + + /** Specifies whether the graph should interpolate between the points. */ + interpolate?: boolean, /** List of distribution components. */ components?: DistributionComponents, diff --git a/src/tracks/track.ts b/src/tracks/track.ts index 8156563..a42536f 100644 --- a/src/tracks/track.ts +++ b/src/tracks/track.ts @@ -5,7 +5,7 @@ import { LegendTriggerFunction } from '../utils/legend-helper'; /** * Default options */ -const defaults = { +const defaults: TrackOptions = { width: 3, maxWidth: null, horizontal: false,