diff --git a/packages/node-modules-inspector/src/app/pages/chart/[...chart].vue b/packages/node-modules-inspector/src/app/pages/chart/[...chart].vue index 3de05691..2ffd8e94 100644 --- a/packages/node-modules-inspector/src/app/pages/chart/[...chart].vue +++ b/packages/node-modules-inspector/src/app/pages/chart/[...chart].vue @@ -11,17 +11,20 @@ import { NuxtLink } from '#components' import ChartFlamegraph from '../../components/chart/Flamegraph.vue' import ChartSunburst from '../../components/chart/Sunburst.vue' import ChartTreemap from '../../components/chart/Treemap.vue' +import DisplayDateBadge from '../../components/display/DateBadge.vue' import DisplayFileSizeBadge from '../../components/display/FileSizeBadge.vue' import DisplayModuleType from '../../components/display/ModuleType' import DisplayPackageSpec from '../../components/display/PackageSpec.vue' import OptionSelectGroup from '../../components/option/SelectGroup.vue' import { isDark } from '../../composables/dark' import { selectedNode } from '../../state/current' -import { payloads } from '../../state/payload' +import { getPublishTime, payloads } from '../../state/payload' +import { query } from '../../state/query' import { settings } from '../../state/settings' import { isSidepanelCollapsed } from '../../state/ui' import { bytesToHumanSize } from '../../utils/format' import { getModuleType } from '../../utils/module-type' +import { compareSemver } from '../../utils/semver' const mouse = reactive(useMouse()) const params = useRoute().params as Record @@ -30,6 +33,115 @@ const nodeHover = shallowRef(undefined) const nodeSelected = shallowRef(undefined) const location = window.location +type ColoringMode = 'spectrum' | 'module' | 'age' | 'duplicated' +const COLORING_MODES = ['spectrum', 'module', 'age', 'duplicated'] as const + +// The coloring mode is persisted in the query string (URL hash) so that it can +// be shared/bookmarked. Default (`spectrum`) is stored as an empty string to +// keep the URL clean. +const coloringMode = computed({ + get() { + return (COLORING_MODES.includes(query.chartColoring as ColoringMode) + ? query.chartColoring + : 'spectrum') as ColoringMode + }, + set(value) { + query.chartColoring = value === 'spectrum' ? '' : value + }, +}) + +const baseShade = computed(() => isDark.value ? '#999' : '#eee') + +const YEAR = 365 * 24 * 60 * 60 * 1000 + +// "Published age" coloring: fresh packages stay neutral, then shift towards +// yellow / orange / red the older their published date is. +function getAgeColor(pkg: PackageNode): string { + const time = getPublishTime(pkg) + if (!time) + return baseShade.value + const age = Date.now() - +time + if (age < YEAR) + return baseShade.value + if (age < 2 * YEAR) + return '#facc15' + if (age < 3 * YEAR) + return '#fb923c' + return '#ef4444' +} + +// Package names that resolve to more than one version. +const duplicatedNames = computed(() => + Array.from(payloads.filtered.versions.entries()) + .filter(([, pkgs]) => pkgs.length > 1) + .map(([name]) => name) + .sort(), +) + +// "Duplicated" coloring: every package name that resolves to more than one +// version gets its own distinct color; all others stay gray. +const duplicatedColors = computed(() => { + const map = new Map() + const names = duplicatedNames.value + names.forEach((name, i) => { + const hue = Math.round((i / Math.max(names.length, 1)) * 360) + map.set(name, `hsl(${hue}, 70%, ${isDark.value ? 62 : 45}%)`) + }) + return map +}) + +// Hovering a package that has multiple versions outlines every block that +// shares its name (i.e. all of its other versions) with a ring. Only the +// Treemap draws it — the other charts don't expose node geometry. +const HIGHLIGHT_COLOR = '#ec4899' +const highlightName = computed(() => { + const name = nodeHover.value?.meta?.name + return name && duplicatedColors.value.has(name) ? name : undefined +}) + +// The publish time of the currently hovered package, if known. +const hoverPublishTime = computed(() => + nodeHover.value?.meta ? getPublishTime(nodeHover.value.meta) : null, +) + +// All resolved versions of the hovered package (only meaningful when > 1), +// sorted by semver for the tooltip's duplicate list. +const hoverVersions = computed(() => { + const meta = nodeHover.value?.meta + if (!meta) + return [] + return [...(payloads.filtered.versions.get(meta.name) ?? [])] + .sort((a, b) => compareSemver(a.version, b.version)) +}) + +// Legend entries for the current color mode (spectrum has none). +const legend = computed<{ background: string, label: string }[] | undefined>(() => { + switch (coloringMode.value) { + case 'module': + return [ + { background: '#4ade80', label: 'ESM' }, + { background: '#2dd4bf', label: 'Dual' }, + { background: '#facc15', label: 'CJS' }, + { background: '#a3e635', label: 'Faux' }, + { background: baseShade.value, label: 'DTS' }, + ] + case 'age': + return [ + { background: baseShade.value, label: '< 1 year' }, + { background: '#facc15', label: '> 1 year' }, + { background: '#fb923c', label: '> 2 years' }, + { background: '#ef4444', label: '> 3 years' }, + ] + case 'duplicated': + return [ + { background: 'linear-gradient(90deg, hsl(0,70%,55%), hsl(120,70%,55%), hsl(240,70%,55%))', label: 'Multiple versions' }, + { background: baseShade.value, label: 'Single version' }, + ] + default: + return undefined + } +}) + const tree = computed(() => { const packages = payloads.filtered.packages const rootDepth = Math.min(...packages.map(i => i.depth)) @@ -149,6 +261,41 @@ const tree = computed(() => { let dispose: () => void | undefined const options = computed>(() => { + const mode = coloringMode.value + const spectrum = createColorGetterSpectrum( + tree.value.root, + isDark.value ? 0.8 : 0.9, + isDark.value ? 1 : 1.1, + ) + const getColor: typeof spectrum = (node) => { + if (mode === 'spectrum') + return spectrum(node) + if (!node.meta) + return undefined + switch (mode) { + case 'module': { + const type = getModuleType(node.meta.resolved.module) + switch (type) { + case 'esm': + return '#4ade80' + case 'cjs': + return '#facc15' + case 'dual': + return '#2dd4bf' + case 'faux': + return '#a3e635' + case 'dts': + return baseShade.value + } + return undefined + } + case 'age': + return getAgeColor(node.meta) + case 'duplicated': + return duplicatedColors.value.get(node.meta.name) ?? baseShade.value + } + } + return { onClick(node) { if (node) @@ -169,38 +316,16 @@ const options = computed>(() => { }, animate: settings.value.chartAnimation, palette: { - stroke: isDark.value ? '#222' : '#555', + stroke: isDark.value ? '#444' : '#555', fg: isDark.value ? '#fff' : '#000', bg: isDark.value ? '#111' : '#fff', }, - getColor: settings.value.chartColoringMode === 'module' - ? (node) => { - if (!node.meta) - return undefined - const type = getModuleType(node.meta?.resolved.module) - switch (type) { - case 'esm': - return '#4ade80' - case 'cjs': - return '#facc15' - case 'dual': - return '#2dd4bf' - case 'faux': - return '#a3e635' - case 'dts': - return '#888888' - } - } - : createColorGetterSpectrum( - tree.value.root, - isDark.value ? 0.8 : 0.9, - isDark.value ? 1 : 1.1, - ), + getColor, getSubtext: (node) => { if (!node.meta) return node.subtext - if (settings.value.chartColoringMode === 'module') { - const type = getModuleType(node.meta?.resolved.module) + if (coloringMode.value === 'module') { + const type = getModuleType(node.meta.resolved.module) return type.toUpperCase() } return node.subtext @@ -217,6 +342,45 @@ function selectNode(node: ChartNode | null, animate?: boolean) { graph.value?.select(node, animate) } +// nanovis has no per-node border, so we wrap the Treemap's `draw()` and, after +// it renders, stroke a ring around every block whose package shares the hovered +// name (its other versions). We reuse the Treemap's own layout boxes via the +// (private) `iterateNodeToDraw` generator, so the rings line up exactly. +interface TreemapLayout { + node: ChartNode + box: [number, number, number, number] + children: TreemapLayout[] +} +interface TreemapInternals { + draw: () => void + c: CanvasRenderingContext2D + layers: { base?: TreemapLayout | null, current?: TreemapLayout | null } + iterateNodeToDraw: (layout: TreemapLayout, culling: number, cullingLayouts: unknown[]) => Iterable +} + +function installTreemapHighlight(treemap: Treemap): void { + const tm = treemap as unknown as TreemapInternals + const original = tm.draw.bind(tm) + tm.draw = () => { + original() + const name = highlightName.value + const layout = tm.layers.current || tm.layers.base + if (!name || !layout) + return + const ctx = tm.c + ctx.save() + ctx.lineWidth = 2 + ctx.strokeStyle = HIGHLIGHT_COLOR + for (const item of tm.iterateNodeToDraw(layout, 0, [])) { + if (item.node.meta?.name !== name) + continue + const [x, y, w, h] = item.box + ctx.strokeRect(x + 1, y + 1, Math.max(w - 2, 1), Math.max(h - 2, 1)) + } + ctx.restore() + } +} + watch( () => [chart.value, tree.value, options.value], () => { @@ -230,11 +394,14 @@ watch( case 'flamegraph': graph.value = new Flamegraph(tree.value.root, options.value) break - default: - graph.value = new Treemap(tree.value.root, { + default: { + const treemap = new Treemap(tree.value.root, { ...options.value, selectedPaddingRatio: 0, }) + installTreemapHighlight(treemap) + graph.value = treemap + } } nextTick(() => { @@ -255,7 +422,16 @@ watch( ) watch( - () => settings.value.chartColoringMode, + () => coloringMode.value, + () => { + graph.value?.draw() + }, +) + +// Redraw so the Treemap hover ring (see installTreemapHighlight) follows the +// currently highlighted package. A no-op for the other charts. +watch( + () => highlightName.value, () => { graph.value?.draw() }, @@ -316,13 +492,27 @@ onUnmounted(() => {
- +
+
+
+ Colorization +
+ +
+
+
+ + {{ item.label }} +
+
+
+
{ / + +
+
+ {{ hoverVersions.length }} versions +
+ v{{ v.version }} +
diff --git a/packages/node-modules-inspector/src/app/state/query.ts b/packages/node-modules-inspector/src/app/state/query.ts index af5c3df9..440288cc 100644 --- a/packages/node-modules-inspector/src/app/state/query.ts +++ b/packages/node-modules-inspector/src/app/state/query.ts @@ -9,6 +9,7 @@ export interface QueryOptions extends Partial<{ [x in keyof FilterOptions]?: str selected?: string install?: string mode?: string + chartColoring?: string selectedAction?: string selectedAuthors?: string actionAll?: string @@ -21,6 +22,7 @@ export const query = reactive({ selected: '', install: '', mode: '', + chartColoring: '', selectedAction: '', selectedAuthors: '', actionAll: '', diff --git a/packages/node-modules-inspector/src/app/state/settings.ts b/packages/node-modules-inspector/src/app/state/settings.ts index 59b02e3d..a65daa87 100644 --- a/packages/node-modules-inspector/src/app/state/settings.ts +++ b/packages/node-modules-inspector/src/app/state/settings.ts @@ -18,7 +18,6 @@ export const SETTINGS_DEFAULT: SettingsOptions = { showMaintainerActions: false, showThirdPartyServices: false, treatFauxAsESM: false, - chartColoringMode: 'spectrum', collapseSidepanel: false, chartAnimation: true, preferNpmx: true, diff --git a/packages/node-modules-inspector/src/shared/types.ts b/packages/node-modules-inspector/src/shared/types.ts index f83a5f4f..2d492190 100644 --- a/packages/node-modules-inspector/src/shared/types.ts +++ b/packages/node-modules-inspector/src/shared/types.ts @@ -87,7 +87,6 @@ export interface SettingsOptions { showPublintMessages: boolean showMaintainerActions: boolean showThirdPartyServices: boolean - chartColoringMode: 'spectrum' | 'module' collapseSidepanel: boolean chartAnimation: boolean preferNpmx: boolean