Skip to content

Commit

Permalink
Add cell interaction on canvas mode
Browse files Browse the repository at this point in the history
  • Loading branch information
vasturiano committed Oct 20, 2023
1 parent f0bc0c9 commit c2b5a3d
Show file tree
Hide file tree
Showing 3 changed files with 109 additions and 34 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
],
"dependencies": {
"accessor-fn": "1",
"canvas-color-tracker": "1",
"d3-axis": "1 - 3",
"d3-hilbert": "^1.3",
"d3-scale": "1 - 4",
Expand Down
118 changes: 90 additions & 28 deletions src/hilbert.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import './canvas-textpath/ctxtextpath.js';
import heatmap from 'heatmap.js';
import Kapsule from 'kapsule';
import accessorFn from 'accessor-fn';
import ColorTracker from 'canvas-color-tracker';

const N_TICKS = Math.pow(2, 3); // Force place ticks on bit boundaries

Expand Down Expand Up @@ -158,12 +159,15 @@ export default Kapsule({
},

stateInit() {
const shadowCanvasEl = document.createElement('canvas');
return {
hilbert: d3Hilbert().simplifyCurves(true),
defaultColorScale: d3ScaleOrdinal(d3SchemePaired),
zoomBox: [[0, 0], [N_TICKS, N_TICKS]],
axisScaleX: d3ScaleLinear().domain([0, N_TICKS]),
axisScaleY: d3ScaleLinear().domain([0, N_TICKS])
axisScaleY: d3ScaleLinear().domain([0, N_TICKS]),
shadowCanvasEl,
shadowCtx: shadowCanvasEl.getContext('2d', { willReadFrequently: true })
};
},

Expand Down Expand Up @@ -240,7 +244,6 @@ export default Kapsule({
.attr('class', 'hilbert-canvas')
.style('display', 'block')
.style('position', 'absolute');
state.hilbertCanvasCtx = hilbertCanvas.node().getContext('2d');

// Zoom binding
hilbertCanvas.call(state.zoom);
Expand Down Expand Up @@ -269,24 +272,61 @@ export default Kapsule({
valTooltip.classed('hilbert-tooltip', true);

hilbertCanvas.on('mouseover', () => state.showValTooltip && valTooltip.style('display', 'inline'));
hilbertCanvas.on('mouseout', () => valTooltip.style('display', 'none'));
hilbertCanvas.on('mouseout', () => {
valTooltip.style('display', 'none');
rangeTooltip.style('display', 'none');

if (state.useCanvas && state.hoverD) {
state.hoverD = null;
state.onRangeHover && state.onRangeHover(null);
}
});
hilbertCanvas.on('click', () => state.useCanvas && state.onRangeClick && state.hoverD && state.onRangeClick(state.hoverD));
hilbertCanvas.on('mousemove', function(ev) {
const coords = d3Pointer(ev);

// Hover detection based on shadow canvas
if (state.useCanvas && (state.onRangeHover || state.showRangeTooltip)) {
const pxScale = window.devicePixelRatio;
const hoverD = state.colorTracker.lookup(state.shadowCtx.getImageData(...coords.map(c => c * pxScale), 1, 1).data);

if (hoverD !== state.hoverD) {
state.hoverD = hoverD;

state.rangeTooltip.style('display', 'none');
if (hoverD) {
if (state.showRangeTooltip) {
state.rangeTooltip.style('display', 'inline');

const d = hoverD;
if (state.rangeTooltipContent) {
state.rangeTooltip.html(accessorFn(state.rangeTooltipContent)(d));
} else {
// default tooltip
const rangeLabel = accessorFn(state.rangeLabel);
const rangeFormatter = d => state.valFormatter(d.start) + (d.length > 1 ? ' - ' + state.valFormatter(d.start + d.length - 1) : '');
state.rangeTooltip.html(`<b>${rangeLabel(d)}</b>: ${rangeFormatter(d)}`);
}
}
}
state.onRangeHover && state.onRangeHover(hoverD || null);
}
}

if (state.showValTooltip) {
let coords = d3Pointer(ev);
const c = coords.slice();
if (state.useCanvas) {
// Need to consider zoom on canvas
const zoomTransform = d3ZoomTransform(state.zoom.__baseElem.node());
coords[0] -= zoomTransform.x;
coords[0] /= zoomTransform.k;
coords[1] -= zoomTransform.y;
coords[1] /= zoomTransform.k;
c[0] -= zoomTransform.x;
c[0] /= zoomTransform.k;
c[1] -= zoomTransform.y;
c[1] /= zoomTransform.k;
}

valTooltip.text(state.valFormatter(state.hilbert.getValAtXY(coords[0], coords[1])))
valTooltip.text(state.valFormatter(state.hilbert.getValAtXY(...c)))
.style('left', `${ev.pageX}px`)
.style('top', `${ev.pageY}px`);
}

if (state.showRangeTooltip) {
rangeTooltip
.style('left', `${ev.pageX}px`)
Expand Down Expand Up @@ -503,31 +543,45 @@ export default Kapsule({

function canvasUpdate() {
const pxScale = window.devicePixelRatio; // 2 on retina displays
const ctx = state.hilbertCanvasCtx;

// canvas resize (and clear)
state.hilbertCanvas
.style('top', `${state.margin}px`)
.style('left', `${state.margin}px`)
.style('width', `${canvasWidth}px`)
.style('height', `${canvasWidth}px`)
.attr('width', state.canvasWidth * pxScale)
.attr('height', state.canvasWidth * pxScale);
.style('height', `${canvasWidth}px`);

ctx.clearRect(0, 0, canvasWidth, canvasWidth);
[state.hilbertCanvas.node(), state.shadowCanvasEl].forEach(canvasEl => {
// Memory size (scaled to avoid blurriness)
canvasEl.width = state.canvasWidth * pxScale;
canvasEl.height = state.canvasWidth * pxScale;
});

// Apply zoom transform (respecting pxScale)
const zoomTransform = d3ZoomTransform(state.zoom.__baseElem.node());
ctx.translate(zoomTransform.x * pxScale, zoomTransform.y * pxScale);
ctx.scale(zoomTransform.k * pxScale, zoomTransform.k * pxScale);

const viewWindow = { // in px
x: -zoomTransform.x / zoomTransform.k,
y: -zoomTransform.y / zoomTransform.k,
len: canvasWidth / zoomTransform.k
};

for (let i = 0, len = state.data.length; i < len ; i++) {
const ctx = state.hilbertCanvas.node().getContext('2d');
const shadowCtx = state.shadowCtx;

[ctx, shadowCtx].forEach(ctx => {
ctx.clearRect(0, 0, canvasWidth, canvasWidth);

// Apply zoom transform (respecting pxScale)
ctx.translate(zoomTransform.x * pxScale, zoomTransform.y * pxScale);
ctx.scale(zoomTransform.k * pxScale, zoomTransform.k * pxScale);
});

// indexed blocks for rgb lookup
const n = state.data.length;
state.colorTracker = new ColorTracker(
n < 250e3 ? 6 : n < 500e3 ? 5 : n < 1e6 ? 4 : n < 2e6 ? 3 : n < 4e6 ? 2 : n < 8e6 ? 1 : 0
);

for (let i = 0; i < n ; i++) {
const d = state.data[i];

const w = d.cellWidth;
Expand All @@ -545,7 +599,12 @@ export default Kapsule({
const rectW = w * (1 - relPadding);

ctx.fillStyle = colorAccessor(d);
ctx.fillRect(x + rectPadding, y + rectPadding, rectW, rectW);
const ctxs = [ctx];
if (scaledW >= 1) { // don't bother registering sub-pixel squares on shadow canvas, it kills performance
shadowCtx.fillStyle = state.colorTracker.register(d);
ctxs.push(shadowCtx);
}
ctxs.forEach(ctx => ctx.fillRect(x + rectPadding, y + rectPadding, rectW, rectW));

if (scaledW > 12) { // Hide labels on small square cells
const name = labelAcessor(d);
Expand Down Expand Up @@ -573,12 +632,15 @@ export default Kapsule({
})];

ctx.strokeStyle = colorAccessor(d);
ctx.lineWidth = w * (1 - relPadding);
ctx.lineCap = 'square';
ctx.beginPath();
ctx.moveTo(...path[0]);
path.slice(1).forEach(([x, y]) => ctx.lineTo(x, y));
ctx.stroke();
shadowCtx.strokeStyle = state.colorTracker.register(d);
[ctx, shadowCtx].forEach(ctx => {
ctx.lineWidth = w * (1 - relPadding);
ctx.lineCap = 'square';
ctx.beginPath();
ctx.moveTo(...path[0]);
path.slice(1).forEach(([x, y]) => ctx.lineTo(x, y));
ctx.stroke();
});

// extend path extremities to cell edges for textpath
const pathStart = path[0].map((c, idx) => c - (path[1][idx] - c) / 2);
Expand Down
24 changes: 18 additions & 6 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1231,6 +1231,13 @@ caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001541:
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001551.tgz#1f2cfa8820bd97c971a57349d7fd8f6e08664a3e"
integrity sha512-vtBAez47BoGMMzlbYhfXrMV1kvRF2WP/lqiMuDu1Sb4EE4LKEgjopFDSRtZfdVnslNRpOqV/woE+Xgrwj6VQlg==

canvas-color-tracker@1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/canvas-color-tracker/-/canvas-color-tracker-1.2.1.tgz#c552872f8f254bac3e74ea4cc7fed3bb19859bf1"
integrity sha512-i5clg2pEdaWqHuEM/B74NZNLkHh5+OkXbA/T4iaBiaNDagkOCXkLNrhqUfdUugsRwuaNRU20e/OygzxWRor3yg==
dependencies:
tinycolor2 "^1.6.0"

chalk@^2.4.2:
version "2.4.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
Expand Down Expand Up @@ -1593,9 +1600,9 @@ eastasianwidth@^0.2.0:
integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==

electron-to-chromium@^1.4.535:
version "1.4.559"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.559.tgz#050483c22c5eb2345017a8976a67b060559a33f4"
integrity sha512-iS7KhLYCSJbdo3rUSkhDTVuFNCV34RKs2UaB9Ecr7VlqzjjWW//0nfsFF5dtDmyXlZQaDYYtID5fjtC/6lpRug==
version "1.4.560"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.560.tgz#f251409f1e8f393d0dfdf9ccb0b39de739a06a17"
integrity sha512-HhJH/pWAxTaPZl7R3mJ6gCd8MfjQdil9RAWk84qHaLsmPTadydfAmq0a1x8kZtOGQ6pZrWhOYj5uZ8I0meZIgg==

emoji-regex@^8.0.0:
version "8.0.0"
Expand Down Expand Up @@ -1832,9 +1839,9 @@ json5@^2.2.3:
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==

kapsule@^1.14:
version "1.14.4"
resolved "https://registry.yarnpkg.com/kapsule/-/kapsule-1.14.4.tgz#55f19fe7a04dfe111e9efc7d85a895e6440ef828"
integrity sha512-Ro1US5B5mtyZMM+NqW/0fqcBf9oEO7fG0gYY9FY+BVGo4KaonVsplFfuYx3pZ/GLCQfYE5cONduILLktsYjUpQ==
version "1.14.5"
resolved "https://registry.yarnpkg.com/kapsule/-/kapsule-1.14.5.tgz#c0bc7c1d4c693ee2647182e5b4ffbf95a4d65f72"
integrity sha512-H0iSpTynUzZw3tgraDmReprpFRmH5oP5GPmaNsurSwLx2H5iCpOMIkp5q+sfhB4Tz/UJd1E1IbEE9Z6ksnJ6RA==
dependencies:
lodash-es "4"

Expand Down Expand Up @@ -2561,6 +2568,11 @@ terser@^5.17.4:
commander "^2.20.0"
source-map-support "~0.5.20"

tinycolor2@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e"
integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==

to-fast-properties@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"
Expand Down

0 comments on commit c2b5a3d

Please sign in to comment.