diff --git a/force-app/main/default/lwc/timeline/timeline.css b/force-app/main/default/lwc/timeline/timeline.css index cc7e346..4978945 100644 --- a/force-app/main/default/lwc/timeline/timeline.css +++ b/force-app/main/default/lwc/timeline/timeline.css @@ -195,6 +195,32 @@ stroke: none; } +/* heatmap cells — warm palette complementary to the cool-blue slider, GitHub-style empty cell */ +.timeline-map-heatmap-cell { + stroke: #ffffff; + stroke-width: 0.75; +} + +.timeline-map-heatmap-cell-l0 { + fill: #f3f4f6; +} + +.timeline-map-heatmap-cell-l1 { + fill: #cfe6f6; +} + +.timeline-map-heatmap-cell-l2 { + fill: #7ec1e8; +} + +.timeline-map-heatmap-cell-l3 { + fill: #2a8fd1; +} + +.timeline-map-heatmap-cell-l4 { + fill: #0d4f86; +} + /* brush */ .brush .selection { stroke: #107cad; diff --git a/force-app/main/default/lwc/timeline/timeline.js b/force-app/main/default/lwc/timeline/timeline.js index 648a81f..81c777b 100755 --- a/force-app/main/default/lwc/timeline/timeline.js +++ b/force-app/main/default/lwc/timeline/timeline.js @@ -54,6 +54,7 @@ export default class timeline extends NavigationMixin(LightningElement) { @api zoomTo; //Zoom to current dat or latest activity @api daysToShow; //number of days to plot for the default zoom @api showToday; //should today's date be plotted and in what colour + @api minimapFormat; //Dotmap (default) or Heatmap rendering for the bottom minimap //Component calculated attributes @api recordId; //current record id of lead, case, opportunity, contact or account @@ -1007,6 +1008,8 @@ export default class timeline extends NavigationMixin(LightningElement) { timelineMap.width = timelineMapDIV.offsetWidth; timelineMap.height = timelineMapDIV.offsetHeight; + // SVG fills the content box (clientHeight) — heatmap layout uses this so cells don't overflow the borders. + timelineMap.innerHeight = timelineMapDIV.clientHeight || timelineMapDIV.offsetHeight; let currentDate = new Date(); @@ -1042,6 +1045,22 @@ export default class timeline extends NavigationMixin(LightningElement) { }) .filter(timelineMap.filter); + timelineMap.width = timelineMap.x.range()[1]; + timelineMapSVG.attr('width', timelineMap.width); + + if (me.minimapFormat === 'Heatmap') { + // Remove dotmap groups left over from prior renders + timelineMap.selectAll('[class~=timeline-map-record]').remove(); + me.renderHeatmap(timelineMap, data); + if (data.length <= 0) { + me.processError('No-Filter-Data', me.error.NO_DATA_HEADER, me.error.NO_DATA_SUBHEADER); + } + return; + } + + // Switching back to Dotmap — clear any heatmap layer. + timelineMap.selectAll('g.timeline-map-heatmap-layer').remove(); + data.sort(me.sortByValue('time')); // calculating vertical layout for displaying data @@ -1060,9 +1079,6 @@ export default class timeline extends NavigationMixin(LightningElement) { return false; }); - timelineMap.width = timelineMap.x.range()[1]; - timelineMapSVG.attr('width', timelineMap.width); - timelineMap.data = timelineMap .selectAll('[class~=timeline-map-record]') .data(data, function (d) { @@ -1097,6 +1113,94 @@ export default class timeline extends NavigationMixin(LightningElement) { return timelineMap; } + renderHeatmap(timelineMap, data) { + const domain = timelineMap.x.domain(); + const totalMs = domain[1] - domain[0]; + + // Cell width is fixed regardless of date range. We pick the largest column count + // that fits at this target, then size every cell identically. + const TARGET_CELL_PX = 12; + const numColumns = Math.max(1, Math.floor(timelineMap.width / TARGET_CELL_PX)); + const bucketMs = totalMs / numColumns; + const cellWidth = timelineMap.width / numColumns; + const bucketDays = bucketMs / (1000 * 60 * 60 * 24); + + const cells = []; + for (let i = 0; i < numColumns; i++) { + cells.push({ index: i, am: 0, pm: 0 }); + } + + data.forEach(function (record) { + const offset = record.time.getTime() - domain[0].getTime(); + if (offset < 0 || offset >= totalMs) return; + const idx = Math.min(numColumns - 1, Math.floor(offset / bucketMs)); + if (record.time.getHours() < 12) { + cells[idx].am += 1; + } else { + cells[idx].pm += 1; + } + }); + + const flat = []; + cells.forEach(function (c) { + flat.push({ index: c.index, period: 'AM', count: c.am }); + flat.push({ index: c.index, period: 'PM', count: c.pm }); + }); + + // Each AM/PM cell covers half a bucket's worth of time, so scale thresholds accordingly. + const halfBucketDays = Math.max(0.5, bucketDays / 2); + const t2 = Math.max(2, Math.round(2 * halfBucketDays)); + const t3 = Math.max(3, Math.round(5 * halfBucketDays)); + const intensity = function (count) { + if (count <= 0) return 0; + if (count <= 1) return 1; + if (count <= t2) return 2; + if (count <= t3) return 3; + return 4; + }; + + const gap = 1; + const heatmapHeight = timelineMap.innerHeight || timelineMap.height; + const rowHeight = Math.max(Math.floor((heatmapHeight - gap * 3) / 2), 4); + const yTop = gap; + const yBottom = heatmapHeight - gap - rowHeight; + const visualWidth = Math.max(cellWidth - 1, 1); + + // Render into a dedicated layer that sits BEHIND the brush group so the slider is never covered. + let heatmapLayer = timelineMap.select('g.timeline-map-heatmap-layer'); + if (heatmapLayer.empty()) { + heatmapLayer = timelineMap.insert('g', ':first-child').attr('class', 'timeline-map-heatmap-layer'); + } + + const join = heatmapLayer.selectAll('[class~=timeline-map-heatmap-cell]').data(flat, function (c) { + return c.index + '|' + c.period; + }); + + join.exit().remove(); + + const enter = join + .enter() + .append('rect') + .attr('class', function (c) { + return 'timeline-map-heatmap-cell timeline-map-heatmap-cell-l' + intensity(c.count); + }) + .attr('rx', 3) + .attr('ry', 3); + + enter.merge(join) + .attr('x', function (c) { + return c.index * cellWidth + 0.5; + }) + .attr('y', function (c) { + return c.period === 'AM' ? yTop : yBottom; + }) + .attr('width', visualWidth) + .attr('height', rowHeight) + .attr('class', function (c) { + return 'timeline-map-heatmap-cell timeline-map-heatmap-cell-l' + intensity(c.count); + }); + } + brush() { const me = this; const d3timeline = me._d3timelineCanvas; @@ -1178,11 +1282,12 @@ export default class timeline extends NavigationMixin(LightningElement) { xBrush.call(brush).call(brush.move, [new Date(startBrush), new Date(endBrush)].map(timelineMap.x)); brush.redraw = function () { + const liveWidth = me.template.querySelector('div.timeline-map').offsetWidth; brush = d3 .brushX() .extent([ [0, 0], - [timelineMap.width, timelineMap.height] + [Math.max(liveWidth, 0), timelineMap.height] ]) .on('brush', brushed) .on('start', brushStart) diff --git a/force-app/main/default/lwc/timeline/timeline.js-meta.xml b/force-app/main/default/lwc/timeline/timeline.js-meta.xml index fbf62c0..e393e49 100755 --- a/force-app/main/default/lwc/timeline/timeline.js-meta.xml +++ b/force-app/main/default/lwc/timeline/timeline.js-meta.xml @@ -81,6 +81,15 @@ description="Show today's date." datasource="No, Blue, Black, Green, Indigo, Pink, Purple, Teal, Red" /> + @@ -159,6 +168,15 @@ description="Show today's date." datasource="No, Blue, Black, Green, Indigo, Pink, Purple, Teal, Red" /> +