fix: simpler structure in the template
This commit is contained in:
@@ -611,9 +611,10 @@ export class BlogGenerationEngine {
|
|||||||
await fs.mkdir(imagesDir, { recursive: true });
|
await fs.mkdir(imagesDir, { recursive: true });
|
||||||
|
|
||||||
for (const [filename, definition] of Object.entries(PREVIEW_ASSETS)) {
|
for (const [filename, definition] of Object.entries(PREVIEW_ASSETS)) {
|
||||||
const sourcePath = require.resolve(definition.modulePath);
|
|
||||||
const destPath = path.join(assetsDir, filename);
|
const destPath = path.join(assetsDir, filename);
|
||||||
const content = await readFile(sourcePath);
|
const content = definition.sourceText !== undefined
|
||||||
|
? Buffer.from(definition.sourceText, 'utf-8')
|
||||||
|
: await readFile(require.resolve(definition.modulePath as string));
|
||||||
await fs.writeFile(destPath, content);
|
await fs.writeFile(destPath, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Liquid } from 'liquidjs';
|
|||||||
import type { MediaData } from './MediaEngine';
|
import type { MediaData } from './MediaEngine';
|
||||||
import type { PostData } from './PostEngine';
|
import type { PostData } from './PostEngine';
|
||||||
import { PICO_THEME_NAMES } from '../shared/picoThemes';
|
import { PICO_THEME_NAMES } from '../shared/picoThemes';
|
||||||
|
import { TAG_CLOUD_RUNTIME_JS } from './assets/tagCloudRuntime';
|
||||||
|
|
||||||
export interface HtmlRewriteContext {
|
export interface HtmlRewriteContext {
|
||||||
canonicalPostPathBySlug: Map<string, string>;
|
canonicalPostPathBySlug: Map<string, string>;
|
||||||
@@ -110,6 +111,12 @@ export interface PostEngineContract {
|
|||||||
getPostsFiltered?: (filter: { status?: 'draft' | 'published' | 'archived' }) => Promise<PostData[]>;
|
getPostsFiltered?: (filter: { status?: 'draft' | 'published' | 'archived' }) => Promise<PostData[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PreviewAssetDefinition {
|
||||||
|
contentType: string;
|
||||||
|
modulePath?: string;
|
||||||
|
sourceText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TagUsageEntry {
|
export interface TagUsageEntry {
|
||||||
tag: string;
|
tag: string;
|
||||||
count: number;
|
count: number;
|
||||||
@@ -131,7 +138,7 @@ export function normalizeTagCloudOrientation(value: string | undefined): TagClou
|
|||||||
return 'horizontal';
|
return 'horizontal';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PREVIEW_ASSETS: Record<string, { modulePath: string; contentType: string }> = {
|
export const PREVIEW_ASSETS: Record<string, PreviewAssetDefinition> = {
|
||||||
'pico.min.css': {
|
'pico.min.css': {
|
||||||
modulePath: '@picocss/pico/css/pico.min.css',
|
modulePath: '@picocss/pico/css/pico.min.css',
|
||||||
contentType: 'text/css; charset=utf-8',
|
contentType: 'text/css; charset=utf-8',
|
||||||
@@ -157,6 +164,10 @@ export const PREVIEW_ASSETS: Record<string, { modulePath: string; contentType: s
|
|||||||
modulePath: 'd3-cloud/build/d3.layout.cloud.js',
|
modulePath: 'd3-cloud/build/d3.layout.cloud.js',
|
||||||
contentType: 'application/javascript; charset=utf-8',
|
contentType: 'application/javascript; charset=utf-8',
|
||||||
},
|
},
|
||||||
|
'tag-cloud.js': {
|
||||||
|
contentType: 'application/javascript; charset=utf-8',
|
||||||
|
sourceText: TAG_CLOUD_RUNTIME_JS,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PREVIEW_IMAGE_ASSETS = {
|
export const PREVIEW_IMAGE_ASSETS = {
|
||||||
|
|||||||
@@ -687,14 +687,15 @@ export class PreviewServer {
|
|||||||
if (!assetDefinition) return null;
|
if (!assetDefinition) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const absolutePath = require.resolve(assetDefinition.modulePath);
|
const body = assetDefinition.sourceText !== undefined
|
||||||
const body = await readFile(absolutePath);
|
? Buffer.from(assetDefinition.sourceText, 'utf-8')
|
||||||
|
: await readFile(require.resolve(assetDefinition.modulePath as string));
|
||||||
return {
|
return {
|
||||||
contentType: assetDefinition.contentType,
|
contentType: assetDefinition.contentType,
|
||||||
body,
|
body,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[PreviewServer] Failed to read local asset: ${assetDefinition.modulePath}`, error);
|
console.error(`[PreviewServer] Failed to read local asset: ${assetDefinition.modulePath ?? assetName}`, error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
272
src/main/engine/assets/tagCloudRuntime.ts
Normal file
272
src/main/engine/assets/tagCloudRuntime.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
export const TAG_CLOUD_RUNTIME_JS = String.raw`(function () {
|
||||||
|
function parseWords(rawWords) {
|
||||||
|
if (!rawWords || typeof rawWords !== 'string') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawWords);
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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);
|
||||||
|
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
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!cloudFactory) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawWords = container.getAttribute('data-tag-cloud-words');
|
||||||
|
const words = parseWords(rawWords);
|
||||||
|
if (words.length === 0) {
|
||||||
|
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';
|
||||||
|
|
||||||
|
const resolveRotation = () => {
|
||||||
|
if (orientation === 'mixed-hv') {
|
||||||
|
return Math.random() < 0.5 ? 0 : 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation === 'mixed-diagonal') {
|
||||||
|
const diagonalAngles = [-60, -30, 0, 30, 60, 90];
|
||||||
|
const index = Math.floor(Math.random() * diagonalAngles.length);
|
||||||
|
return diagonalAngles[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const svgNode = container.querySelector('svg.tag-cloud-canvas');
|
||||||
|
if (!svgNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (svgNode.firstChild) {
|
||||||
|
svgNode.removeChild(svgNode.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
cloudFactory()
|
||||||
|
.size([width, height])
|
||||||
|
.words(coloredWords.map((word) => ({ ...word })))
|
||||||
|
.padding(4)
|
||||||
|
.rotate(() => resolveRotation())
|
||||||
|
.font('sans-serif')
|
||||||
|
.fontSize((word) => word.size)
|
||||||
|
.on('end', (layoutWords) => {
|
||||||
|
svgNode.setAttribute('viewBox', '0 0 ' + width + ' ' + height);
|
||||||
|
svgNode.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||||||
|
|
||||||
|
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||||
|
group.setAttribute('transform', 'translate(' + (width / 2) + ',' + (height / 2) + ')');
|
||||||
|
|
||||||
|
for (const word of layoutWords) {
|
||||||
|
const textNode = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||||
|
textNode.textContent = word.text;
|
||||||
|
textNode.setAttribute('text-anchor', 'middle');
|
||||||
|
textNode.setAttribute('transform', 'translate(' + word.x + ',' + word.y + ')rotate(' + (word.rotate || 0) + ')');
|
||||||
|
textNode.style.fontFamily = 'sans-serif';
|
||||||
|
textNode.style.fontSize = word.size + 'px';
|
||||||
|
textNode.style.fill = typeof word.color === 'string' && word.color.length > 0
|
||||||
|
? word.color
|
||||||
|
: 'currentColor';
|
||||||
|
textNode.style.cursor = 'pointer';
|
||||||
|
textNode.style.opacity = '0.9';
|
||||||
|
|
||||||
|
const titleNode = document.createElementNS('http://www.w3.org/2000/svg', 'title');
|
||||||
|
titleNode.textContent = word.text + ' (' + word.count + ')';
|
||||||
|
textNode.appendChild(titleNode);
|
||||||
|
|
||||||
|
textNode.addEventListener('click', () => {
|
||||||
|
if (word && typeof word.url === 'string' && word.url.length > 0) {
|
||||||
|
window.location.assign(word.url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
group.appendChild(textNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
svgNode.appendChild(group);
|
||||||
|
})
|
||||||
|
.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTagClouds() {
|
||||||
|
const containers = document.querySelectorAll('[data-tag-cloud="true"]');
|
||||||
|
containers.forEach((container) => drawTagCloud(container));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initTagClouds, { once: true });
|
||||||
|
} else {
|
||||||
|
initTagClouds();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
`;
|
||||||
@@ -7,278 +7,6 @@
|
|||||||
<link rel="stylesheet" href="/assets/lightbox.min.css" />
|
<link rel="stylesheet" href="/assets/lightbox.min.css" />
|
||||||
{% render 'partials/styles' %}
|
{% render 'partials/styles' %}
|
||||||
<script defer src="/assets/d3.layout.cloud.js"></script>
|
<script defer src="/assets/d3.layout.cloud.js"></script>
|
||||||
|
<script defer src="/assets/tag-cloud.js"></script>
|
||||||
<script defer src="/assets/lightbox.min.js"></script>
|
<script defer src="/assets/lightbox.min.js"></script>
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
function parseWords(rawWords) {
|
|
||||||
if (!rawWords || typeof rawWords !== 'string') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(rawWords);
|
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!cloudFactory) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawWords = container.getAttribute('data-tag-cloud-words');
|
|
||||||
const words = parseWords(rawWords);
|
|
||||||
if (words.length === 0) {
|
|
||||||
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';
|
|
||||||
|
|
||||||
const resolveRotation = () => {
|
|
||||||
if (orientation === 'mixed-hv') {
|
|
||||||
return Math.random() < 0.5 ? 0 : 90;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (orientation === 'mixed-diagonal') {
|
|
||||||
const diagonalAngles = [-60, -30, 0, 30, 60, 90];
|
|
||||||
const index = Math.floor(Math.random() * diagonalAngles.length);
|
|
||||||
return diagonalAngles[index];
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const svgNode = container.querySelector('svg.tag-cloud-canvas');
|
|
||||||
if (!svgNode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (svgNode.firstChild) {
|
|
||||||
svgNode.removeChild(svgNode.firstChild);
|
|
||||||
}
|
|
||||||
|
|
||||||
cloudFactory()
|
|
||||||
.size([width, height])
|
|
||||||
.words(coloredWords.map((word) => ({ ...word })))
|
|
||||||
.padding(4)
|
|
||||||
.rotate(() => resolveRotation())
|
|
||||||
.font('sans-serif')
|
|
||||||
.fontSize((word) => word.size)
|
|
||||||
.on('end', (layoutWords) => {
|
|
||||||
svgNode.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
|
||||||
svgNode.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
|
||||||
|
|
||||||
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
||||||
group.setAttribute('transform', `translate(${width / 2},${height / 2})`);
|
|
||||||
|
|
||||||
for (const word of layoutWords) {
|
|
||||||
const textNode = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
||||||
textNode.textContent = word.text;
|
|
||||||
textNode.setAttribute('text-anchor', 'middle');
|
|
||||||
textNode.setAttribute('transform', `translate(${word.x},${word.y})rotate(${word.rotate || 0})`);
|
|
||||||
textNode.style.fontFamily = 'sans-serif';
|
|
||||||
textNode.style.fontSize = `${word.size}px`;
|
|
||||||
textNode.style.fill = typeof word.color === 'string' && word.color.length > 0
|
|
||||||
? word.color
|
|
||||||
: 'currentColor';
|
|
||||||
textNode.style.cursor = 'pointer';
|
|
||||||
textNode.style.opacity = '0.9';
|
|
||||||
|
|
||||||
const titleNode = document.createElementNS('http://www.w3.org/2000/svg', 'title');
|
|
||||||
titleNode.textContent = `${word.text} (${word.count})`;
|
|
||||||
textNode.appendChild(titleNode);
|
|
||||||
|
|
||||||
textNode.addEventListener('click', () => {
|
|
||||||
if (word && typeof word.url === 'string' && word.url.length > 0) {
|
|
||||||
window.location.assign(word.url);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
group.appendChild(textNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
svgNode.appendChild(group);
|
|
||||||
})
|
|
||||||
.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
function initTagClouds() {
|
|
||||||
const containers = document.querySelectorAll('[data-tag-cloud="true"]');
|
|
||||||
containers.forEach((container) => drawTagCloud(container));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initTagClouds, { once: true });
|
|
||||||
} else {
|
|
||||||
initTagClouds();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ describe('BlogGenerationEngine', () => {
|
|||||||
expect(await fileExists(path.join(tempDir, 'html', 'assets', 'pico.min.css'))).toBe(true);
|
expect(await fileExists(path.join(tempDir, 'html', 'assets', 'pico.min.css'))).toBe(true);
|
||||||
expect(await fileExists(path.join(tempDir, 'html', 'assets', 'lightbox.min.css'))).toBe(true);
|
expect(await fileExists(path.join(tempDir, 'html', 'assets', 'lightbox.min.css'))).toBe(true);
|
||||||
expect(await fileExists(path.join(tempDir, 'html', 'assets', 'lightbox.min.js'))).toBe(true);
|
expect(await fileExists(path.join(tempDir, 'html', 'assets', 'lightbox.min.js'))).toBe(true);
|
||||||
|
expect(await fileExists(path.join(tempDir, 'html', 'assets', 'tag-cloud.js'))).toBe(true);
|
||||||
expect(await fileExists(path.join(tempDir, 'html', 'images', 'prev.png'))).toBe(true);
|
expect(await fileExists(path.join(tempDir, 'html', 'images', 'prev.png'))).toBe(true);
|
||||||
expect(await fileExists(path.join(tempDir, 'html', 'images', 'next.png'))).toBe(true);
|
expect(await fileExists(path.join(tempDir, 'html', 'images', 'next.png'))).toBe(true);
|
||||||
expect(await fileExists(path.join(tempDir, 'html', 'images', 'close.png'))).toBe(true);
|
expect(await fileExists(path.join(tempDir, 'html', 'images', 'close.png'))).toBe(true);
|
||||||
@@ -209,6 +210,8 @@ describe('BlogGenerationEngine', () => {
|
|||||||
expect(html).toContain('data-template="post-list"');
|
expect(html).toContain('data-template="post-list"');
|
||||||
expect(html).toContain('/assets/pico.min.css');
|
expect(html).toContain('/assets/pico.min.css');
|
||||||
expect(html).toContain('/assets/lightbox.min.css');
|
expect(html).toContain('/assets/lightbox.min.css');
|
||||||
|
expect(html).toContain('/assets/tag-cloud.js');
|
||||||
|
expect(html).not.toContain('function parseWords(');
|
||||||
expect(html).toContain('archive-day-marker');
|
expect(html).toContain('archive-day-marker');
|
||||||
expect(html).toContain('15.01.2025');
|
expect(html).toContain('15.01.2025');
|
||||||
expect(html).toContain('14.01.2025');
|
expect(html).toContain('14.01.2025');
|
||||||
|
|||||||
@@ -178,6 +178,8 @@ describe('PreviewServer', () => {
|
|||||||
expect(rootHtml).toContain('href="/assets/lightbox.min.css"');
|
expect(rootHtml).toContain('href="/assets/lightbox.min.css"');
|
||||||
expect(rootHtml).toContain('src="/assets/lightbox.min.js"');
|
expect(rootHtml).toContain('src="/assets/lightbox.min.js"');
|
||||||
expect(rootHtml).toContain('src="/assets/d3.layout.cloud.js"');
|
expect(rootHtml).toContain('src="/assets/d3.layout.cloud.js"');
|
||||||
|
expect(rootHtml).toContain('src="/assets/tag-cloud.js"');
|
||||||
|
expect(rootHtml).not.toContain('function parseWords(');
|
||||||
expect(rootHtml).not.toContain('cdn.jsdelivr.net');
|
expect(rootHtml).not.toContain('cdn.jsdelivr.net');
|
||||||
|
|
||||||
const picoResponse = await fetch(`${server.getBaseUrl()}/assets/pico.min.css`);
|
const picoResponse = await fetch(`${server.getBaseUrl()}/assets/pico.min.css`);
|
||||||
@@ -196,6 +198,10 @@ describe('PreviewServer', () => {
|
|||||||
expect(d3CloudJsResponse.status).toBe(200);
|
expect(d3CloudJsResponse.status).toBe(200);
|
||||||
expect(d3CloudJsResponse.headers.get('content-type')).toContain('application/javascript');
|
expect(d3CloudJsResponse.headers.get('content-type')).toContain('application/javascript');
|
||||||
|
|
||||||
|
const tagCloudJsResponse = await fetch(`${server.getBaseUrl()}/assets/tag-cloud.js`);
|
||||||
|
expect(tagCloudJsResponse.status).toBe(200);
|
||||||
|
expect(tagCloudJsResponse.headers.get('content-type')).toContain('application/javascript');
|
||||||
|
|
||||||
const lightboxPrevImageResponse = await fetch(`${server.getBaseUrl()}/images/prev.png`);
|
const lightboxPrevImageResponse = await fetch(`${server.getBaseUrl()}/images/prev.png`);
|
||||||
expect(lightboxPrevImageResponse.status).toBe(200);
|
expect(lightboxPrevImageResponse.status).toBe(200);
|
||||||
expect(lightboxPrevImageResponse.headers.get('content-type')).toContain('image/png');
|
expect(lightboxPrevImageResponse.headers.get('content-type')).toContain('image/png');
|
||||||
|
|||||||
Reference in New Issue
Block a user