diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index c226a6d..c245c1d 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -176,7 +176,9 @@ Use macros when you need reusable rich blocks (for example embedded videos, medi - `[[tag_cloud orientation="mixed_diagonal" width="900" height="420"]]` - Builds a word cloud from published tag usage counts. - Word size scales by usage quantity. - - Word color scales by quantity from least to most: blue → green → yellow → orange → red. + - Word color is theme-aware and uses Pico CSS semantic colors. + - Colors are distributed by quantity quantiles with easing, so dense datasets still show visible variation. + - Visual order remains least-to-most: blue → green → yellow → orange → red. - Clicking a word opens that tag archive route. - `orientation` is optional and supports: - `horizontal` (all words horizontal) diff --git a/src/main/engine/PageRenderer.ts b/src/main/engine/PageRenderer.ts index 560df6a..84179d8 100644 --- a/src/main/engine/PageRenderer.ts +++ b/src/main/engine/PageRenderer.ts @@ -131,42 +131,6 @@ export function normalizeTagCloudOrientation(value: string | undefined): TagClou return 'horizontal'; } -function interpolateChannel(start: number, end: number, t: number): number { - return Math.round(start + ((end - start) * t)); -} - -export function resolveTagCloudColor(normalizedRatio: number): string { - if (normalizedRatio >= 1) { - return 'red'; - } - - if (normalizedRatio <= 0) { - return 'blue'; - } - - const palette = [ - [0, 0, 255], - [0, 128, 0], - [255, 255, 0], - [255, 165, 0], - [255, 0, 0], - ]; - - const scaled = normalizedRatio * (palette.length - 1); - const lowerIndex = Math.floor(scaled); - const upperIndex = Math.min(palette.length - 1, lowerIndex + 1); - const segmentRatio = scaled - lowerIndex; - - const lowerColor = palette[lowerIndex]; - const upperColor = palette[upperIndex]; - - const red = interpolateChannel(lowerColor[0], upperColor[0], segmentRatio); - const green = interpolateChannel(lowerColor[1], upperColor[1], segmentRatio); - const blue = interpolateChannel(lowerColor[2], upperColor[2], segmentRatio); - - return `rgb(${red},${green},${blue})`; -} - export const PREVIEW_ASSETS: Record = { 'pico.min.css': { modulePath: '@picocss/pico/css/pico.min.css', @@ -447,7 +411,7 @@ export function renderTagCloudMacro(params: Record, tagUsage: Ta const height = heightParam && heightParam >= 180 && heightParam <= 900 ? heightParam : 420; if (tagUsage.length === 0) { - return `
No tags found.
`; + return `
No tags found.
`; } const minCount = Math.min(...tagUsage.map((entry) => entry.count)); @@ -456,9 +420,6 @@ export function renderTagCloudMacro(params: Record, tagUsage: Ta const maxFont = 56; const words = tagUsage.map((entry) => { - const ratio = maxCount === minCount - ? 1 - : (entry.count - minCount) / (maxCount - minCount); const normalizedSize = maxCount === minCount ? Math.round((minFont + maxFont) / 2) : Math.round(minFont + ((entry.count - minCount) / (maxCount - minCount)) * (maxFont - minFont)); @@ -467,14 +428,13 @@ export function renderTagCloudMacro(params: Record, tagUsage: Ta text: entry.tag, size: normalizedSize, count: entry.count, - color: resolveTagCloudColor(ratio), url: `/tag/${encodeURIComponent(entry.tag)}/`, }; }); const wordsJson = escapeHtml(JSON.stringify(words)); - return `
`; + return `
`; } export function isExternalOrSpecialUrl(value: string): boolean { diff --git a/src/main/engine/templates/partials/head.liquid b/src/main/engine/templates/partials/head.liquid index 1fe5899..754ce29 100644 --- a/src/main/engine/templates/partials/head.liquid +++ b/src/main/engine/templates/partials/head.liquid @@ -23,6 +23,159 @@ } } + function clamp01(value) { + if (!Number.isFinite(value)) { + return 0; + } + + if (value < 0) { + return 0; + } + + if (value > 1) { + return 1; + } + + return value; + } + + function parseCssColor(colorValue) { + if (typeof colorValue !== 'string') { + return null; + } + + const value = colorValue.trim(); + if (!value) { + return null; + } + + const hexMatch = value.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (hexMatch) { + const hex = hexMatch[1]; + if (hex.length === 3) { + return [ + Number.parseInt(`${hex[0]}${hex[0]}`, 16), + Number.parseInt(`${hex[1]}${hex[1]}`, 16), + Number.parseInt(`${hex[2]}${hex[2]}`, 16), + ]; + } + + return [ + Number.parseInt(hex.slice(0, 2), 16), + Number.parseInt(hex.slice(2, 4), 16), + Number.parseInt(hex.slice(4, 6), 16), + ]; + } + + const rgbMatch = value.match(/^rgba?\(([^)]+)\)$/i); + if (rgbMatch) { + const channels = rgbMatch[1] + .split(',') + .map((channel) => channel.trim()) + .slice(0, 3) + .map((channel) => { + if (channel.endsWith('%')) { + return Math.round((Number.parseFloat(channel) / 100) * 255); + } + + return Math.round(Number.parseFloat(channel)); + }); + + if (channels.length === 3 && channels.every((channel) => Number.isFinite(channel))) { + return channels.map((channel) => Math.max(0, Math.min(255, channel))); + } + } + + return null; + } + + function interpolateColor(fromColor, toColor, t) { + return [ + Math.round(fromColor[0] + ((toColor[0] - fromColor[0]) * t)), + Math.round(fromColor[1] + ((toColor[1] - fromColor[1]) * t)), + Math.round(fromColor[2] + ((toColor[2] - fromColor[2]) * t)), + ]; + } + + function mixColor(fromColor, toColor, weight) { + return interpolateColor(fromColor, toColor, clamp01(weight)); + } + + function colorToCss(color) { + return `rgb(${color[0]},${color[1]},${color[2]})`; + } + + function getPicoThemeStops() { + const style = window.getComputedStyle(document.documentElement); + + const blue = parseCssColor(style.getPropertyValue('--pico-secondary')) || [74, 99, 146]; + const green = parseCssColor(style.getPropertyValue('--pico-ins-color')) || [53, 117, 56]; + const red = parseCssColor(style.getPropertyValue('--pico-del-color')) || [183, 72, 72]; + + const yellow = mixColor(green, red, 0.45); + const orange = mixColor(green, red, 0.72); + + return [blue, green, yellow, orange, red]; + } + + function interpolateStops(stops, value) { + if (!Array.isArray(stops) || stops.length === 0) { + return 'currentColor'; + } + + if (stops.length === 1) { + return colorToCss(stops[0]); + } + + const clamped = clamp01(value); + const scaled = clamped * (stops.length - 1); + const lowerIndex = Math.floor(scaled); + const upperIndex = Math.min(stops.length - 1, lowerIndex + 1); + const localT = scaled - lowerIndex; + + return colorToCss(interpolateColor(stops[lowerIndex], stops[upperIndex], localT)); + } + + function resolveQuantileColorMap(words, easingGamma) { + const counts = Array.from( + new Set(words.map((word) => Number(word.count)).filter((count) => Number.isFinite(count))), + ).sort((a, b) => a - b); + + const quantiles = new Map(); + if (counts.length === 0) { + return quantiles; + } + + if (counts.length === 1) { + quantiles.set(counts[0], 1); + return quantiles; + } + + counts.forEach((count, index) => { + quantiles.set(count, index / (counts.length - 1)); + }); + + return quantiles; + } + + function applyThemeAwareColors(words, container) { + const gammaRaw = Number.parseFloat(container.getAttribute('data-color-easing') || '0.7'); + const gamma = Number.isFinite(gammaRaw) && gammaRaw > 0 ? gammaRaw : 0.7; + const quantiles = resolveQuantileColorMap(words, gamma); + const stops = getPicoThemeStops(); + + return words.map((word) => { + const count = Number(word.count); + const quantile = quantiles.get(count) ?? 0; + const eased = Math.pow(clamp01(quantile), gamma); + + return { + ...word, + color: interpolateStops(stops, eased), + }; + }); + } + function drawTagCloud(container) { const cloudFactory = window.d3 && window.d3.layout && typeof window.d3.layout.cloud === 'function' ? window.d3.layout.cloud @@ -38,6 +191,12 @@ return; } + const colorDistribution = container.getAttribute('data-color-distribution') || 'quantile'; + const colorTheme = container.getAttribute('data-color-theme') || 'pico'; + const coloredWords = colorDistribution === 'quantile' && colorTheme === 'pico' + ? applyThemeAwareColors(words, container) + : words; + const width = Number.parseInt(container.getAttribute('data-width') || '900', 10) || 900; const height = Number.parseInt(container.getAttribute('data-height') || '420', 10) || 420; const orientation = container.getAttribute('data-orientation') || 'horizontal'; @@ -67,7 +226,7 @@ cloudFactory() .size([width, height]) - .words(words.map((word) => ({ ...word }))) + .words(coloredWords.map((word) => ({ ...word }))) .padding(4) .rotate(() => resolveRotation()) .font('sans-serif') diff --git a/tests/engine/PreviewServer.test.ts b/tests/engine/PreviewServer.test.ts index 0e5ba75..d4910c5 100644 --- a/tests/engine/PreviewServer.test.ts +++ b/tests/engine/PreviewServer.test.ts @@ -243,13 +243,17 @@ describe('PreviewServer', () => { expect(html).toContain('class="macro-tag-cloud"'); expect(html).toContain('data-tag-cloud="true"'); expect(html).toContain('data-orientation="horizontal"'); + expect(html).toContain('data-color-distribution="quantile"'); + expect(html).toContain('data-color-easing="0.7"'); + expect(html).toContain('data-color-theme="pico"'); expect(html).toContain('TypeScript'); expect(html).toContain('/tag/TypeScript/'); expect(html).toContain('/tag/Electron/'); expect(html).toContain('/tag/SQLite/'); - expect(html).toContain('"color":"rgb('); - expect(html).toContain('"color":"red"'); - expect(html).toContain('"color":"blue"'); + expect(html).toContain('"count":3'); + expect(html).toContain('"count":2'); + expect(html).toContain('"count":1'); + expect(html).not.toContain('"color"'); }); it('supports tag_cloud orientation parameter modes', async () => {