feat: better looking
This commit is contained in:
@@ -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<string, { modulePath: string; contentType: string }> = {
|
||||
'pico.min.css': {
|
||||
modulePath: '@picocss/pico/css/pico.min.css',
|
||||
@@ -447,7 +411,7 @@ export function renderTagCloudMacro(params: Record<string, string>, tagUsage: Ta
|
||||
const height = heightParam && heightParam >= 180 && heightParam <= 900 ? heightParam : 420;
|
||||
|
||||
if (tagUsage.length === 0) {
|
||||
return `<div class="macro-tag-cloud" data-tag-cloud="true" data-orientation="${orientation}"><div class="tag-cloud-empty">No tags found.</div></div>`;
|
||||
return `<div class="macro-tag-cloud" data-tag-cloud="true" data-orientation="${orientation}" data-color-distribution="quantile" data-color-easing="0.7" data-color-theme="pico"><div class="tag-cloud-empty">No tags found.</div></div>`;
|
||||
}
|
||||
|
||||
const minCount = Math.min(...tagUsage.map((entry) => entry.count));
|
||||
@@ -456,9 +420,6 @@ export function renderTagCloudMacro(params: Record<string, string>, 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<string, string>, 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 `<div class="macro-tag-cloud" data-tag-cloud="true" data-orientation="${orientation}" data-tag-cloud-words="${wordsJson}" data-width="${width}" data-height="${height}"><svg class="tag-cloud-canvas" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid meet" aria-label="Tag cloud"></svg></div>`;
|
||||
return `<div class="macro-tag-cloud" data-tag-cloud="true" data-orientation="${orientation}" data-color-distribution="quantile" data-color-easing="0.7" data-color-theme="pico" data-tag-cloud-words="${wordsJson}" data-width="${width}" data-height="${height}"><svg class="tag-cloud-canvas" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid meet" aria-label="Tag cloud"></svg></div>`;
|
||||
}
|
||||
|
||||
export function isExternalOrSpecialUrl(value: string): boolean {
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user