Move heatmap to first-party code (#37262)
Replaces `@silverwind/vue3-calendar-heatmap` with an inlined SVG implementation. Renders pixel-identically to `main`, drops the `onMounted` legend viewBox workaround, and uses tippy's `createSingleton` for the hover tooltip. Adds an e2e test for tooltip display. This is a prereq for migrating tippy.js to [floating-ui](https://github.com/floating-ui/floating-ui) to avoid having two tooltip libs active. <img width="861" height="168" alt="image" src="https://github.com/user-attachments/assets/99343cf6-6e09-42c7-a80d-63dbf33cf56a" /> --- This PR was written with the help of Claude Opus 4.7 --------- Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com> Co-authored-by: Nicolas <bircni@icloud.com>
This commit is contained in:
@@ -34,7 +34,6 @@
|
|||||||
"@replit/codemirror-lang-svelte": "6.0.0",
|
"@replit/codemirror-lang-svelte": "6.0.0",
|
||||||
"@replit/codemirror-vscode-keymap": "6.0.2",
|
"@replit/codemirror-vscode-keymap": "6.0.2",
|
||||||
"@resvg/resvg-wasm": "2.6.2",
|
"@resvg/resvg-wasm": "2.6.2",
|
||||||
"@silverwind/vue3-calendar-heatmap": "2.1.1",
|
|
||||||
"@vitejs/plugin-vue": "6.0.6",
|
"@vitejs/plugin-vue": "6.0.6",
|
||||||
"ansi_up": "6.0.6",
|
"ansi_up": "6.0.6",
|
||||||
"asciinema-player": "3.15.1",
|
"asciinema-player": "3.15.1",
|
||||||
|
|||||||
Generated
-15
@@ -112,9 +112,6 @@ importers:
|
|||||||
'@resvg/resvg-wasm':
|
'@resvg/resvg-wasm':
|
||||||
specifier: 2.6.2
|
specifier: 2.6.2
|
||||||
version: 2.6.2
|
version: 2.6.2
|
||||||
'@silverwind/vue3-calendar-heatmap':
|
|
||||||
specifier: 2.1.1
|
|
||||||
version: 2.1.1(tippy.js@6.3.7)(vue@3.5.32(typescript@6.0.2))
|
|
||||||
'@vitejs/plugin-vue':
|
'@vitejs/plugin-vue':
|
||||||
specifier: 6.0.6
|
specifier: 6.0.6
|
||||||
version: 6.0.6(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1))(vue@3.5.32(typescript@6.0.2))
|
version: 6.0.6(vite@8.0.8(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1))(vue@3.5.32(typescript@6.0.2))
|
||||||
@@ -1249,13 +1246,6 @@ packages:
|
|||||||
'@scarf/scarf@1.4.0':
|
'@scarf/scarf@1.4.0':
|
||||||
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
|
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
|
||||||
|
|
||||||
'@silverwind/vue3-calendar-heatmap@2.1.1':
|
|
||||||
resolution: {integrity: sha512-RQtLOpkysm0LR3PbUoc+aDcYxzy7xboygb1SQEwrUm2/XB2nmt0BEra2ADXpu4kwFxtk0+IyNwzFvbBai/wvTg==}
|
|
||||||
engines: {node: '>=16'}
|
|
||||||
peerDependencies:
|
|
||||||
tippy.js: ^6.3.7
|
|
||||||
vue: ^3.2.29
|
|
||||||
|
|
||||||
'@simonwep/pickr@1.9.0':
|
'@simonwep/pickr@1.9.0':
|
||||||
resolution: {integrity: sha512-oEYvv15PyfZzjoAzvXYt3UyNGwzsrpFxLaZKzkOSd0WYBVwLd19iJerePDONxC1iF6+DpcswPdLIM2KzCJuYFg==}
|
resolution: {integrity: sha512-oEYvv15PyfZzjoAzvXYt3UyNGwzsrpFxLaZKzkOSd0WYBVwLd19iJerePDONxC1iF6+DpcswPdLIM2KzCJuYFg==}
|
||||||
|
|
||||||
@@ -5052,11 +5042,6 @@ snapshots:
|
|||||||
|
|
||||||
'@scarf/scarf@1.4.0': {}
|
'@scarf/scarf@1.4.0': {}
|
||||||
|
|
||||||
'@silverwind/vue3-calendar-heatmap@2.1.1(tippy.js@6.3.7)(vue@3.5.32(typescript@6.0.2))':
|
|
||||||
dependencies:
|
|
||||||
tippy.js: 6.3.7
|
|
||||||
vue: 3.5.32(typescript@6.0.2)
|
|
||||||
|
|
||||||
'@simonwep/pickr@1.9.0':
|
'@simonwep/pickr@1.9.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
core-js: 3.32.2
|
core-js: 3.32.2
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import {test, expect} from '@playwright/test';
|
||||||
|
import {login} from './utils.ts';
|
||||||
|
|
||||||
|
test('heatmap tooltip shows on hover', async ({page}) => {
|
||||||
|
await login(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('.heatmap-day').first().hover();
|
||||||
|
await expect(page.locator('.tippy-box[data-state="visible"]')).toBeVisible();
|
||||||
|
});
|
||||||
@@ -32,26 +32,29 @@
|
|||||||
fill: currentcolor !important;
|
fill: currentcolor !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* root legend */
|
#user-heatmap .heatmap-footer {
|
||||||
#user-heatmap .vch__container > .vch__legend {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* for the "Less" and "More" legend */
|
/* "Less [colors] More" scale */
|
||||||
#user-heatmap .vch__legend .vch__legend {
|
#user-heatmap .heatmap-legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: right;
|
justify-content: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
#user-heatmap .vch__legend .vch__legend div:first-child,
|
#user-heatmap .heatmap-legend-svg {
|
||||||
#user-heatmap .vch__legend .vch__legend div:last-child {
|
margin-right: -12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-heatmap .heatmap-legend > div:first-child,
|
||||||
|
#user-heatmap .heatmap-legend > div:last-child {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#user-heatmap .vch__day__square:hover {
|
#user-heatmap .heatmap-day:hover {
|
||||||
outline: 1.5px solid var(--color-text);
|
outline: 1.5px solid var(--color-text);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
// TODO: Switch to upstream after https://github.com/razorness/vue3-calendar-heatmap/pull/34 is merged
|
import {computed, onBeforeUnmount, onMounted} from 'vue';
|
||||||
import {CalendarHeatmap} from '@silverwind/vue3-calendar-heatmap';
|
import tippy, {createSingleton} from 'tippy.js';
|
||||||
import {onMounted, shallowRef} from 'vue';
|
import type {CreateSingletonInstance, Instance} from 'tippy.js';
|
||||||
import type {Value as HeatmapValue, Locale as HeatmapLocale} from '@silverwind/vue3-calendar-heatmap';
|
|
||||||
|
|
||||||
defineProps<{
|
type HeatmapValue = {date: Date; count: number};
|
||||||
|
type HeatmapCell = {date: Date; colorIndex: number; ariaLabel: string; tooltip: string};
|
||||||
|
type MonthLabel = {monthIdx: number; weekIdx: number};
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
values: HeatmapValue[];
|
values: HeatmapValue[];
|
||||||
locale: {
|
locale: {
|
||||||
textTotalContributions: string;
|
textTotalContributions: string;
|
||||||
heatMapLocale: Partial<HeatmapLocale>;
|
heatMapLocale: {months: string[]; days: string[]; on: string; more: string; less: string};
|
||||||
noDataText: string;
|
noDataText: string;
|
||||||
tooltipUnit: string;
|
tooltipUnit: string;
|
||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const colorRange = [
|
const colorRange = [
|
||||||
'var(--color-secondary-alpha-60)',
|
|
||||||
'var(--color-secondary-alpha-60)',
|
'var(--color-secondary-alpha-60)',
|
||||||
'var(--color-primary-light-4)',
|
'var(--color-primary-light-4)',
|
||||||
'var(--color-primary-light-2)',
|
'var(--color-primary-light-2)',
|
||||||
@@ -24,21 +26,112 @@ const colorRange = [
|
|||||||
'var(--color-primary-dark-4)',
|
'var(--color-primary-dark-4)',
|
||||||
];
|
];
|
||||||
|
|
||||||
const endDate = shallowRef(new Date());
|
const squareSize = 10;
|
||||||
|
const squareBorder = 2;
|
||||||
|
const cellSize = squareSize + squareBorder;
|
||||||
|
const daysInWeek = 7;
|
||||||
|
const trailingDays = 365;
|
||||||
|
const gridLeft = Math.ceil(squareSize * 2.5);
|
||||||
|
const gridTop = squareSize + squareSize / 2;
|
||||||
|
|
||||||
onMounted(() => {
|
const now = new Date();
|
||||||
// work around issue with first legend color being rendered twice and legend cut off
|
|
||||||
const legend = document.querySelector<HTMLElement>('.vch__external-legend-wrapper')!;
|
function dateKey(d: Date): string {
|
||||||
legend.setAttribute('viewBox', '12 0 80 10');
|
return `${d.getFullYear()}${String(d.getMonth()).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
|
||||||
legend.style.marginRight = '-12px';
|
}
|
||||||
|
|
||||||
|
function shiftDate(d: Date, days: number): Date {
|
||||||
|
const out = new Date(d);
|
||||||
|
out.setDate(out.getDate() + days);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const grid = computed(() => {
|
||||||
|
const start = shiftDate(now, -trailingDays);
|
||||||
|
const padStart = start.getDay();
|
||||||
|
const padEnd = daysInWeek - 1 - now.getDay();
|
||||||
|
const weekCount = (trailingDays + 1 + padStart + padEnd) / daysInWeek;
|
||||||
|
|
||||||
|
const maxCount = props.values.length ? Math.max(...props.values.map((v) => v.count)) : 0;
|
||||||
|
const max = maxCount > 0 ? Math.ceil(maxCount / 5 * 4) : 1;
|
||||||
|
|
||||||
|
const activities = new Map<string, {count: number; colorIndex: number}>();
|
||||||
|
for (const {date, count} of props.values) {
|
||||||
|
const colorIndex = count >= max ? 4 : Math.max(1, Math.ceil((count / max) * 3));
|
||||||
|
activities.set(dateKey(date), {count, colorIndex});
|
||||||
|
}
|
||||||
|
|
||||||
|
const {months, on} = props.locale.heatMapLocale;
|
||||||
|
const {noDataText, tooltipUnit} = props.locale;
|
||||||
|
|
||||||
|
const cursorStart = shiftDate(start, -padStart);
|
||||||
|
const cursor = new Date(cursorStart.getFullYear(), cursorStart.getMonth(), cursorStart.getDate());
|
||||||
|
const calendar: HeatmapCell[][] = [];
|
||||||
|
for (let w = 0; w < weekCount; w++) {
|
||||||
|
const week: HeatmapCell[] = [];
|
||||||
|
for (let d = 0; d < daysInWeek; d++) {
|
||||||
|
const hit = activities.get(dateKey(cursor));
|
||||||
|
const dateStr = `${months[cursor.getMonth()]} ${cursor.getDate()}, ${cursor.getFullYear()}`;
|
||||||
|
const head = hit ? `${hit.count} ${tooltipUnit}` : noDataText;
|
||||||
|
week.push({
|
||||||
|
date: new Date(cursor),
|
||||||
|
colorIndex: hit ? hit.colorIndex : 0,
|
||||||
|
ariaLabel: `${head} ${on} ${dateStr}`,
|
||||||
|
tooltip: `<b>${head}</b> ${on} ${dateStr}`,
|
||||||
|
});
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
}
|
||||||
|
calendar.push(week);
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthLabels: MonthLabel[] = [];
|
||||||
|
for (let w = 1; w < calendar.length; w++) {
|
||||||
|
const prev = calendar[w - 1][0].date;
|
||||||
|
const curr = calendar[w][0].date;
|
||||||
|
if (prev.getMonth() !== curr.getMonth()) {
|
||||||
|
monthLabels.push({monthIdx: curr.getMonth(), weekIdx: w});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = gridLeft + (cellSize * weekCount) + squareBorder;
|
||||||
|
const height = gridTop + (cellSize * daysInWeek);
|
||||||
|
return {calendar, monthLabels, width, height};
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleDayClick(e: Event & {date: Date}) {
|
const legendViewBox = `${cellSize} 0 ${squareSize * (colorRange.length + 2)} ${squareSize}`;
|
||||||
// Reset filter if same date is clicked
|
|
||||||
|
const cellInstances = new Map<Element, Instance>();
|
||||||
|
let singleton: CreateSingletonInstance | null = null;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
singleton = createSingleton([], {
|
||||||
|
overrides: [],
|
||||||
|
moveTransition: 'transform 0.1s ease-out',
|
||||||
|
allowHTML: true,
|
||||||
|
theme: 'tooltip',
|
||||||
|
role: 'tooltip',
|
||||||
|
placement: 'top',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
singleton?.destroy();
|
||||||
|
for (const instance of cellInstances.values()) instance.destroy();
|
||||||
|
cellInstances.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
function lazyInitTooltip(e: MouseEvent) {
|
||||||
|
const el = e.target as Element;
|
||||||
|
if (!singleton || cellInstances.has(el) || !el.classList.contains('heatmap-day')) return;
|
||||||
|
cellInstances.set(el, tippy(el, {content: el.getAttribute('data-tooltip')!}));
|
||||||
|
singleton.setInstances([...cellInstances.values()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDayClick(date: Date) {
|
||||||
const params = new URLSearchParams(document.location.search);
|
const params = new URLSearchParams(document.location.search);
|
||||||
const queryDate = params.get('date');
|
const queryDate = params.get('date');
|
||||||
// Timezone has to be stripped because toISOString() converts to UTC
|
// Timezone has to be stripped because toISOString() converts to UTC
|
||||||
const clickedDate = new Date(e.date.getTime() - (e.date.getTimezoneOffset() * 60000)).toISOString().substring(0, 10);
|
const clickedDate = new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().substring(0, 10);
|
||||||
|
|
||||||
if (queryDate && queryDate === clickedDate) {
|
if (queryDate && queryDate === clickedDate) {
|
||||||
params.delete('date');
|
params.delete('date');
|
||||||
@@ -53,16 +146,63 @@ function handleDayClick(e: Event & {date: Date}) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<calendar-heatmap
|
<div>
|
||||||
:locale="locale.heatMapLocale"
|
<svg class="heatmap-svg" :viewBox="`0 0 ${grid.width} ${grid.height}`">
|
||||||
:no-data-text="locale.noDataText"
|
<g class="heatmap-month-labels" :transform="`translate(${gridLeft}, 0)`">
|
||||||
:tooltip-unit="locale.tooltipUnit"
|
<text
|
||||||
:end-date="endDate"
|
v-for="m in grid.monthLabels"
|
||||||
:values="values"
|
:key="m.weekIdx"
|
||||||
:range-color="colorRange"
|
class="heatmap-month-label"
|
||||||
@day-click="handleDayClick($event)"
|
:x="cellSize * m.weekIdx"
|
||||||
:tippy-props="{theme: 'tooltip'}"
|
:y="cellSize - squareBorder"
|
||||||
>
|
>
|
||||||
<template #vch__legend-left>{{ locale.textTotalContributions }}</template>
|
{{ locale.heatMapLocale.months[m.monthIdx] }}
|
||||||
</calendar-heatmap>
|
</text>
|
||||||
|
</g>
|
||||||
|
<g class="heatmap-day-labels" :transform="`translate(0, ${gridTop})`">
|
||||||
|
<text class="heatmap-day-label" :x="0" :y="20">{{ locale.heatMapLocale.days[1] }}</text>
|
||||||
|
<text class="heatmap-day-label" :x="0" :y="44">{{ locale.heatMapLocale.days[3] }}</text>
|
||||||
|
<text class="heatmap-day-label" :x="0" :y="69">{{ locale.heatMapLocale.days[5] }}</text>
|
||||||
|
</g>
|
||||||
|
<g class="heatmap-grid" :transform="`translate(${gridLeft}, ${gridTop})`" @mouseover="lazyInitTooltip">
|
||||||
|
<g
|
||||||
|
v-for="(week, w) in grid.calendar"
|
||||||
|
:key="w"
|
||||||
|
class="heatmap-week"
|
||||||
|
:transform="`translate(${w * cellSize}, 0)`"
|
||||||
|
>
|
||||||
|
<template v-for="(day, d) in week" :key="d">
|
||||||
|
<rect
|
||||||
|
v-if="day.date < now"
|
||||||
|
class="heatmap-day"
|
||||||
|
:transform="`translate(0, ${d * cellSize})`"
|
||||||
|
:width="squareSize"
|
||||||
|
:height="squareSize"
|
||||||
|
:style="{fill: colorRange[day.colorIndex]}"
|
||||||
|
:aria-label="day.ariaLabel"
|
||||||
|
:data-tooltip="day.tooltip"
|
||||||
|
@click="handleDayClick(day.date)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<div class="heatmap-footer">
|
||||||
|
<div>{{ locale.textTotalContributions }}</div>
|
||||||
|
<div class="heatmap-legend">
|
||||||
|
<div>{{ locale.heatMapLocale.less }}</div>
|
||||||
|
<svg class="heatmap-legend-svg" :viewBox="legendViewBox" :height="squareSize">
|
||||||
|
<rect
|
||||||
|
v-for="(color, i) in colorRange"
|
||||||
|
:key="i"
|
||||||
|
:width="squareSize"
|
||||||
|
:height="squareSize"
|
||||||
|
:x="(i + 1) * cellSize"
|
||||||
|
:style="{fill: color}"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div>{{ locale.heatMapLocale.more }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user