From f211673727fb45193a0f0962148d5b3f2ea76492 Mon Sep 17 00:00:00 2001 From: Youssef El Houti Date: Thu, 23 May 2024 04:08:31 +0100 Subject: [PATCH] feat: multiple segment selection --- example/random-data.js | 5 +- src/index.d.ts | 3 +- src/timelines.css | 1 - src/timelines.js | 103 +++++++++++++++++++++++++++++------------ 4 files changed, 78 insertions(+), 34 deletions(-) diff --git a/example/random-data.js b/example/random-data.js index cc642de..35aeff0 100644 --- a/example/random-data.js +++ b/example/random-data.js @@ -39,11 +39,12 @@ function getRandomData(ordinal = false) { return { timeRange: [start, end], - val: ordinal ? categoryLabels[Math.ceil(Math.random()*nCategories)] : Math.random() + val: ordinal ? categoryLabels[Math.ceil(Math.random()*nCategories)] : Math.random(), //labelVal: is optional - only displayed in the labels + //selected: is optional, allow to preselect items }; }); } } -} \ No newline at end of file +} diff --git a/src/index.d.ts b/src/index.d.ts index a3bedc1..a27af08 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -11,6 +11,7 @@ export interface Line { export interface Segment { timeRange: [TS, TS]; val: Val; + selected?: boolean; } export type TS = Date | number; @@ -107,7 +108,7 @@ export interface TimelinesChartGenericInstance { label: string, val: Val, timeRange: Range - }) => void): ChainableInstance; + }[]) => void): ChainableInstance; segmentTooltipContent(cb: (segment: { group: string, diff --git a/src/timelines.css b/src/timelines.css index 1d0a717..4314a2e 100644 --- a/src/timelines.css +++ b/src/timelines.css @@ -47,7 +47,6 @@ } .series-segment { - stroke: none; resize: horizontal; } diff --git a/src/timelines.js b/src/timelines.js index 4b079c1..2d03dc1 100644 --- a/src/timelines.js +++ b/src/timelines.js @@ -84,13 +84,14 @@ export default Kapsule({ for (let j= 0, jlen=rawData[i].data.length; j new Date(d)), val, labelVal: labelVal !== undefined ? labelVal : val, + selected, data: rawData[i].data[j].data[k] }); } @@ -976,7 +977,7 @@ export default Kapsule({ let timelines = state.graph.selectAll('rect.series-segment').data( state.flatData.filter(dataFilter), - d => d.group + d.label + d.timeRange[0] + d => d.group + d.label + d.timeRange[0] + d.selected ); timelines.exit() @@ -1002,6 +1003,10 @@ export default Kapsule({ .on('mouseover.segmentTooltip', state.segmentTooltip.show) .on('mouseout.segmentTooltip', state.segmentTooltip.hide); + newSegments.filter(d => d.selected) + .attr('stroke', "rgb(99,229,227)") + .attr('stroke-width', 3) + const resizeOffset = 10; newSegments @@ -1064,27 +1069,58 @@ export default Kapsule({ .attr('height', state.lineHeight) .style('fill-opacity', .8); }) - .on('click', function (ev, s) { - if (state.onSegmentClick) - state.onSegmentClick(s); + .on('click', function (ev, d) { + if (ev.ctrlKey) { + if (d.selected) { + d.selected= false; + d3Select(this) + .attr('stroke', null) + } else { + d.selected = true; + d3Select(this) + .attr('stroke', "rgb(99,229,227)") + .attr('stroke-width', 3); + } + } else { + clearSelection(); + if (state.onSegmentClick) { + state.onSegmentClick(d); + } + } }); - let startWidth; - let startX; + function getSelectedSegmentsOrPassedSegmentIfNoSelection(segment) { + if (segment.datum().selected) { + return state.graph.selectAll('rect.series-segment').filter(d => d.selected).nodes().map(d3Select); + } else { + return [segment] + } + } + + function clearSelection() { + state.graph.selectAll('rect.series-segment').filter(d => d.selected).attr('stroke', null).each(function(d) { + d.selected = false; + }); + } + + let startWidths; + let startXs; let startPointerX; let dragBehavior; newSegments.call(d3Drag() .on('start', function (ev) { const segment = d3Select(this); - startWidth = +segment.attr('width'); - startX = +segment.attr('x'); + const segmentWidth = +segment.attr('width'); + const mouseX = ev.x - +segment.attr('x'); + + startWidths = getSelectedSegmentsOrPassedSegmentIfNoSelection(segment).map(s => +s.attr('width')); + startXs = getSelectedSegmentsOrPassedSegmentIfNoSelection(segment).map(s => +s.attr('x')); startPointerX = d3Pointer(ev, this)[0]; - const mouseX = ev.x - startX; if (mouseX <= resizeOffset) { // Close to the left side dragBehavior = 'resizeLeft'; - } else if (mouseX >= startWidth - resizeOffset) { // Close to the right side + } else if (mouseX >= segmentWidth - resizeOffset) { // Close to the right side dragBehavior = 'resizeRight'; } else { dragBehavior = 'move'; @@ -1093,33 +1129,40 @@ export default Kapsule({ }) .on('drag', function (ev) { const segment = d3Select(this); + const segments = getSelectedSegmentsOrPassedSegmentIfNoSelection(segment); const currentPointerX = d3Pointer(ev, this)[0]; const xDiff = currentPointerX - startPointerX; + dragging = true; - if (dragBehavior === 'resizeLeft') { - segment.attr('width', startWidth - xDiff).attr('x', startX + xDiff); - } else if (dragBehavior === 'resizeRight') { - segment.attr('width', startWidth + xDiff); - } else { - segment.attr('x', startX + xDiff); + for (let i= 0; i < segments.length; i++) { + const segment = segments[i]; + if (dragBehavior === 'resizeLeft') { + segment.attr('width', startWidths[i] - xDiff).attr('x', startXs[i] + xDiff); + } else if (dragBehavior === 'resizeRight') { + segment.attr('width', startWidths[i] + xDiff); + } else { + segment.attr('x', startXs[i] + xDiff); + } } }) - .on('end', function (ev, d) { + .on('end', function () { const segment = d3Select(this); + const segments = getSelectedSegmentsOrPassedSegmentIfNoSelection(segment); + for (const segment of segments) { + const newWidth = +segment.attr('width'); + const newX = +segment.attr('x'); + const timeRangeStart = state.xScale.invert(newX); + const timeRangeEnd = state.xScale.invert(newX + newWidth); + + segment.datum({ ...segment.datum(), timeRange: [timeRangeStart, timeRangeEnd] }); + } + if (state.onSegmentResize && dragging) { + state.onSegmentResize(segments.map(s => s.datum())); + } segment.attr('cursor', 'pointer'); - const newWidth = +segment.attr('width'); - const newX = +segment.attr('x'); - const timeRangeStart = state.xScale.invert(newX); - const timeRangeEnd = state.xScale.invert(newX + newWidth); - - // Update the timeRange in the data object 'd' - d.timeRange = [timeRangeStart, timeRangeEnd]; - if (state.onSegmentResize && dragging) - state.onSegmentResize(d); - - startWidth = undefined; - startX = undefined; + startWidths = undefined; + startXs = undefined; startPointerX = undefined; dragBehavior = undefined; dragging = false;