diff --git a/common/changes/@visactor/vchart-extension/fix-2d-chart-camera_2026-09-22.json b/common/changes/@visactor/vchart-extension/fix-2d-chart-camera_2026-09-22.json new file mode 100644 index 0000000000..6224835f9c --- /dev/null +++ b/common/changes/@visactor/vchart-extension/fix-2d-chart-camera_2026-09-22.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vchart-extension", + "comment": "fix: 修复注册 3D 扩展后二维图表被误判为 3D,导致带缩放自定义图元在动画和悬停时闪烁的问题", + "type": "patch" + } + ], + "packageName": "@visactor/vchart-extension" +} diff --git a/docs/assets/examples/en/lieflat-charts/aggregate-sankey.md b/docs/assets/examples/en/lieflat-charts/aggregate-sankey.md new file mode 100644 index 0000000000..41f9b5db05 --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/aggregate-sankey.md @@ -0,0 +1,122 @@ +--- +category: examples +group: lieflat charts +title: G22 · Aggregate Sankey +keywords: lieflat,G22,editorial,theme +order: 55 +cover: /vchart/preview/lieflat-charts-aggregate-sankey.png +--- + +# G22 · Aggregate Sankey + +Channels pour into plans · ribbon width = accounts. + +A VChart implementation of G22 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Native entrance animation is enabled by default; animated stories start their player automatically. +- Native VChart series, axes and data binding. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const axis = orient => ({ + orient, + domainLine: { visible: false }, + tick: { visible: false }, + grid: { visible: orient === 'left', style: { lineDash: [2, 4] } }, + label: { style: { fontSize: 10, fontFamily: font } } +}); +const base = { + padding: { top: 24, right: 30, bottom: 30, left: 30 }, + title: { + text: 'Aggregate Sankey', + subtext: 'Channels pour into plans · ribbon width = accounts', + textStyle: { fontFamily: font, fontSize: 19 }, + subtextStyle: { fontFamily: font, fontSize: 11 }, + padding: { bottom: 28 } + }, + animationAppear: { duration: 900, easing: 'quartOut' }, + legends: { visible: false } +}; + +const nodes = ['Search', 'Social', 'Partners', 'Referral', 'Free', 'Pro', 'Team'].map(name => ({ name })); +const links = [ + [0, 4, 180], + [0, 5, 120], + [0, 6, 60], + [1, 4, 140], + [1, 5, 55], + [1, 6, 25], + [2, 4, 60], + [2, 5, 85], + [2, 6, 105], + [3, 4, 90], + [3, 5, 65], + [3, 6, 40] +].map(([source, target, value]) => ({ source, target, value })); +const spec = { + animation: true, + ...base, + type: 'sankey', + data: [{ id: 'flow', values: [{ nodes, links }] }], + categoryField: 'name', + valueField: 'value', + nodeAlign: 'justify', + nodeWidth: 8, + nodeGap: 18, + node: { style: { fill: paint(shades[0]) } }, + link: { style: { fillOpacity: 0.35 }, state: { hover: { fillOpacity: 0.8 } } }, + label: { visible: true, style: { fill: paint(ink), fontSize: 12 } } +}; +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/arc-matrix.md b/docs/assets/examples/en/lieflat-charts/arc-matrix.md new file mode 100644 index 0000000000..304e00eefb --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/arc-matrix.md @@ -0,0 +1,242 @@ +--- +category: examples +group: lieflat charts +title: L4 · Arc matrix +keywords: lieflat,L4,editorial,theme +order: 20 +cover: /vchart/preview/lieflat-charts-arc-matrix.png +--- + +# L4 · Arc matrix + +Eight products in twelve cities · bubble area = paying accounts. + +A VChart implementation of L4 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const path = (value, stroke = ink, lineWidth = 1, fill = false, detail = '') => + put('path', { path: value, stroke, lineWidth, fill }, detail); + +const products = ['Editor', 'Boards', 'Docs', 'Flows', 'Chat', 'Vault', 'Pages', 'Sync']; +const cities = ['SF', 'NYC', 'LON', 'BER', 'TOK', 'SYD', 'SIN', 'PAR', 'AMS', 'TOR', 'SEO', 'SAO']; +products.forEach((name, row) => { + const points = cities.map((city, col) => [115 + col * 42, 105 + row * 30 + 20 * Math.sin((col / 11) * Math.PI)]); + path(points.map((p, i) => (i ? 'L' : 'M') + p.join(',')).join(' '), grid, 0.7); + text(98, points[0][1], name, 10, muted, 'right'); + cities.forEach((city, col) => { + const value = Math.round(noise(col, row + 11) * 100); + dot( + ...points[col], + Math.sqrt(value) * 0.85, + row % 2 ? muted : ink, + name + ' / ' + city + ': ' + value + ' accounts' + ); + }); +}); +cities.forEach((city, i) => text(115 + i * 42, 375, city, 9)); + +text(30, 28, 'Arc matrix', 19, ink, 'left', 700); +text(30, 52, 'Eight products in twelve cities · bubble area = paying accounts', 10, muted, 'left'); +text(30, 408, 'L4 / ARC MATRIX / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/ballot-tally.md b/docs/assets/examples/en/lieflat-charts/ballot-tally.md new file mode 100644 index 0000000000..3a395d0715 --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/ballot-tally.md @@ -0,0 +1,235 @@ +--- +category: examples +group: lieflat charts +title: L15 · Ballot tally +keywords: lieflat,L15,editorial,theme +order: 31 +cover: /vchart/preview/lieflat-charts-ballot-tally.png +--- + +# L15 · Ballot tally + +What they fear, tick by tick · multiple responses · one tick = 1%. + +A VChart implementation of L15 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const answers = [ + ['SAME PAY, MORE WORK', 51], + ['UNSUSTAINABLE PACE', 46], + ['QUALITY SLIPPING', 41], + ['REPLACED OUTRIGHT', 22] +]; +answers.forEach(([name, value], row) => { + const y = 118 + row * 67; + text(48, y - 16, name, 11, ink, 'left', 600); + for (let i = 0; i < 100; i++) + line(48 + i * 5.05, y, 48 + i * 5.05, y + 15, i < value ? ink : grid, 1, name + ': ' + value + '%'); + text(584, y + 7, value + '%', 21, ink, 'center', 800); +}); +text(30, 28, 'Ballot tally', 19, ink, 'left', 700); +text(30, 52, 'What they fear, tick by tick · multiple responses · one tick = 1%', 10, muted, 'left'); +text(30, 408, 'L15 / BALLOT TALLY / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/bar-race.md b/docs/assets/examples/en/lieflat-charts/bar-race.md new file mode 100644 index 0000000000..cbe9d763ab --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/bar-race.md @@ -0,0 +1,166 @@ +--- +category: examples +group: lieflat charts +title: G16 · Bar race +keywords: lieflat,G16,editorial,theme +order: 49 +cover: /vchart/preview/lieflat-charts-bar-race.png +--- + +# G16 · Bar race + +Eight products race · monthly active accounts · play / pause below. + +A VChart implementation of G16 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Native entrance animation is enabled by default; animated stories start their player automatically. +- Native VChart series, axes and data binding. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const axis = orient => ({ + orient, + domainLine: { visible: false }, + tick: { visible: false }, + grid: { visible: orient === 'left', style: { lineDash: [2, 4] } }, + label: { style: { fontSize: 10, fontFamily: font } } +}); +const base = { + padding: { top: 24, right: 30, bottom: 30, left: 30 }, + title: { + text: 'Bar race', + subtext: 'Eight products race · monthly active accounts · play / pause below', + textStyle: { fontFamily: font, fontSize: 19 }, + subtextStyle: { fontFamily: font, fontSize: 11 }, + padding: { bottom: 28 } + }, + animationAppear: { duration: 900, easing: 'quartOut' }, + legends: { visible: false } +}; + +const products = ['EDITOR', 'FLOWS', 'DOCS', 'API', 'CHAT', 'SYNC', 'VAULT', 'BOARDS']; +const frames = Array.from({ length: 12 }, (_, month) => ({ + data: [ + { + id: 'race', + values: products + .map((product, i) => ({ + product, + users: Math.round(30 + i * 5 + month * (3 + (7 - i) * 1.5) + Math.sin(month + i) * 15) + })) + .sort((a, b) => b.users - a.users) + } + ], + title: { ...base.title, subtext: 'MONTH ' + (month + 1) + ' / 2026 · ACTIVE ACCOUNTS (k)' } +})); + +frames.forEach(frame => + frame.data.push({ + id: 'frameLabel', + values: [ + { + label: frame.title.subtext + } + ] + }) +); +const spec = { + animation: true, + ...base, + type: 'bar', + direction: 'horizontal', + data: frames[0].data, + xField: 'users', + yField: 'product', + seriesField: 'product', + dataKey: 'product', + color: { type: 'ordinal', domain: products }, + axes: [ + { ...axis('bottom'), min: 0, max: 200 }, + { ...axis('left'), grid: { visible: false } } + ], + bar: { style: { cornerRadius: 6 } }, + barMaxWidth: 25, + label: { visible: true, position: 'outside', style: { fill: paint(ink), fontWeight: 800 } }, + animationUpdate: { duration: 700, easing: 'cubicInOut' }, + + customMark: [ + { + type: 'text', + dataId: 'frameLabel', + style: { + x: 32, + y: 89, + text: d => d.label, + textAlign: 'left', + textBaseline: 'middle', + fontFamily: font, + fontSize: 12, + fill: paint(ink), + fontWeight: 600 + } + } + ], + title: { ...base.title, padding: { bottom: 45 } }, + player: { + type: 'discrete', + orient: 'bottom', + auto: true, + loop: true, + interval: 1500, + specs: frames + } +}; +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/barcode-lollipop.md b/docs/assets/examples/en/lieflat-charts/barcode-lollipop.md new file mode 100644 index 0000000000..e0200164d2 --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/barcode-lollipop.md @@ -0,0 +1,241 @@ +--- +category: examples +group: lieflat charts +title: L3 · Barcode lollipop +keywords: lieflat,L3,editorial,theme +order: 19 +cover: /vchart/preview/lieflat-charts-barcode-lollipop.png +--- + +# L3 · Barcode lollipop + +Ninety days as a barcode · Apr–Jun · peak concurrent users. + +A VChart implementation of L3 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const values = Array.from({ length: 90 }, (_, i) => + Math.round(120 + 55 * Math.sin(i / 10) + 25 * Math.sin(i / 4) + noise(i, 7) * 20) +); +const peaks = [...values.keys()] + .sort((a, b) => values[b] - values[a]) + .filter((i, j, indices) => indices.slice(0, j).every(k => Math.abs(k - i) > 6)) + .slice(0, 3); +values.forEach((value, i) => { + const x = 44 + i * 6.2, + y = 350 - value; + line(x, 92, x, 350, grid, 0.6); + line(x, y, x, y + 24, ink, 0.8); + dot(x, y, peaks.includes(i) ? 4 : 2, i % 7 > 4 ? paper : ink, 'Day ' + (i + 1) + ': ' + value + 'k users', ink); + if (peaks.includes(i)) text(x, y - 13, value + 'k', 11, ink, 'center', 800); +}); +['APR', 'MAY', 'JUN'].forEach((m, i) => text(44 + i * 186, 374, m, 11, muted, 'left')); + +text(30, 28, 'Barcode lollipop', 19, ink, 'left', 700); +text(30, 52, 'Ninety days as a barcode · Apr–Jun · peak concurrent users', 10, muted, 'left'); +text(30, 408, 'L3 / BARCODE LOLLIPOP / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/brand-spectrum.md b/docs/assets/examples/en/lieflat-charts/brand-spectrum.md new file mode 100644 index 0000000000..77f6e51509 --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/brand-spectrum.md @@ -0,0 +1,239 @@ +--- +category: examples +group: lieflat charts +title: L7 · Brand spectrum +keywords: lieflat,L7,editorial,theme +order: 23 +cover: /vchart/preview/lieflat-charts-brand-spectrum.png +--- + +# L7 · Brand spectrum + +Where the brand sits · customer panel · big dot = us. + +A VChart implementation of L7 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const traits = [ + ['FRIEND', 'AUTHORITY', 0.38], + ['SERIOUS', 'PLAYFUL', 0.62], + ['RELIABLE', 'RISK-TAKING', 0.27], + ['CONTEMPORARY', 'CLASSIC', 0.42] +]; +traits.forEach(([left, right, position], i) => { + const y = 121 + i * 66; + text(125, y, left, 10, muted, 'right'); + text(515, y, right, 10, muted, 'left'); + line(150, y, 490, y, grid); + for (let k = 0; k <= 20; k++) line(150 + k * 17, y - 3, 150 + k * 17, y + 3, grid); + for (let k = 0; k < 6; k++) dot(150 + noise(k, i + 22) * 340, y, 2.3, muted, 'Competitor ' + (k + 1)); + dot(150 + position * 340, y, 7, ink, left + ' ↔ ' + right + ': ' + Math.round(position * 100) + '/100'); +}); +text(30, 28, 'Brand spectrum', 19, ink, 'left', 700); +text(30, 52, 'Where the brand sits · customer panel · big dot = us', 10, muted, 'left'); +text(30, 408, 'L7 / BRAND SPECTRUM / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/bubble-almanac.md b/docs/assets/examples/en/lieflat-charts/bubble-almanac.md new file mode 100644 index 0000000000..95aac92dcb --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/bubble-almanac.md @@ -0,0 +1,239 @@ +--- +category: examples +group: lieflat charts +title: L9 · Bubble almanac +keywords: lieflat,L9,editorial,theme +order: 25 +cover: /vchart/preview/lieflat-charts-bubble-almanac.png +--- + +# L9 · Bubble almanac + +Eight years of tickets · bubble area = tickets · inner circle = escalations. + +A VChart implementation of L9 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const products = ['EDITOR', 'BOARDS', 'DOCS', 'CHAT', 'FLOWS', 'VAULT', 'PAGES', 'SYNC']; +products.forEach((name, row) => { + const y = 110 + row * 34; + text(91, y, name, 9, muted, 'right'); + line(109, y, 601, y); + for (let year = 0; year < 8; year++) { + const tickets = Math.round(20 + noise(year, row + 12) * 230), + escalations = Math.round(tickets * (0.06 + noise(year, row) * 0.15)); + const x = 127 + year * 64; + dot(x, y, Math.sqrt(tickets) * 1.05, shades[1], name + ' / ' + (2019 + year) + ': ' + tickets + ' tickets'); + dot(x, y, Math.sqrt(escalations) * 1.05, ink, name + ' / ' + (2019 + year) + ': ' + escalations + ' escalations'); + } +}); +for (let year = 0; year < 8; year++) text(127 + year * 64, 377, 2019 + year, 10); + +text(30, 28, 'Bubble almanac', 19, ink, 'left', 700); +text(30, 52, 'Eight years of tickets · bubble area = tickets · inner circle = escalations', 10, muted, 'left'); +text(30, 408, 'L9 / BUBBLE ALMANAC / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/calendar-heat.md b/docs/assets/examples/en/lieflat-charts/calendar-heat.md new file mode 100644 index 0000000000..1fd5f847f6 --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/calendar-heat.md @@ -0,0 +1,251 @@ +--- +category: examples +group: lieflat charts +title: L17 · Calendar heat +keywords: lieflat,L17,editorial,theme +order: 33 +cover: /vchart/preview/lieflat-charts-calendar-heat.png +--- + +# L17 · Calendar heat + +A year of deploys, day by day · 2025 · one cell = one calendar day. + +A VChart implementation of L17 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const rect = (x, y, width, height, fill = ink, detail = '', stroke = fill) => + put('rect', { x, y, width, height, fill, stroke, lineWidth: 0.6 }, detail); + +const start = Date.UTC(2025, 0, 1); +const offset = (new Date(start).getUTCDay() + 6) % 7; +const months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']; +for (let i = 0; i < 365; i++) { + const date = new Date(start + i * 86400000), + week = Math.floor((i + offset) / 7), + day = (i + offset) % 7; + const value = Math.floor(noise(i, 14) * (day < 5 ? 18 : 7)); + const x = 48 + week * 10.4, + y = 151 + day * 24; + rect( + x, + y, + 8.2, + 18, + value === 0 ? grid : sequential[5 - Math.min(5, Math.floor(value / 3))], + date.toISOString().slice(0, 10) + ': ' + value + ' deploys' + ); + if (date.getUTCDate() === 1) text(x, 130, months[date.getUTCMonth()], 9, muted, 'left'); +} +['MON', 'WED', 'FRI', 'SUN'].forEach((day, i) => text(38, 160 + i * 48, day, 8, muted, 'right')); +text(590, 348, 'LESS', 9, muted, 'right'); +sequential + .slice() + .reverse() + .forEach((shade, i) => rect(480 + i * 17, 363, 12, 12, shade)); +text(475, 369, '0', 9, muted, 'right'); +text(590, 369, '18', 9, muted, 'left'); + +text(30, 28, 'Calendar heat', 19, ink, 'left', 700); +text(30, 52, 'A year of deploys, day by day · 2025 · one cell = one calendar day', 10, muted, 'left'); +text(30, 408, 'L17 / CALENDAR HEAT / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/chunky-bars.md b/docs/assets/examples/en/lieflat-charts/chunky-bars.md new file mode 100644 index 0000000000..d5827a95ad --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/chunky-bars.md @@ -0,0 +1,115 @@ +--- +category: examples +group: lieflat charts +title: G3 · Chunky bars +keywords: lieflat,G3,editorial,theme +order: 36 +cover: /vchart/preview/lieflat-charts-chunky-bars.png +--- + +# G3 · Chunky bars + +Revenue by plan · monthly recurring revenue, $k. + +A VChart implementation of G3 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Native entrance animation is enabled by default; animated stories start their player automatically. +- Native VChart series, axes and data binding. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const axis = orient => ({ + orient, + domainLine: { visible: false }, + tick: { visible: false }, + grid: { visible: orient === 'left', style: { lineDash: [2, 4] } }, + label: { style: { fontSize: 10, fontFamily: font } } +}); +const base = { + padding: { top: 24, right: 30, bottom: 30, left: 30 }, + title: { + text: 'Chunky bars', + subtext: 'Revenue by plan · monthly recurring revenue, $k', + textStyle: { fontFamily: font, fontSize: 19 }, + subtextStyle: { fontFamily: font, fontSize: 11 }, + padding: { bottom: 28 } + }, + animationAppear: { duration: 900, easing: 'quartOut' }, + legends: { visible: false } +}; + +const values = ['FREE', 'STARTER', 'PRO', 'TEAM', 'SCALE', 'ENT'].map((plan, i) => ({ + plan, + revenue: [38, 27, 22, 16, 11, 7][i] +})); +const spec = { + animation: true, + ...base, + type: 'bar', + data: [{ id: 'plans', values }], + xField: 'plan', + yField: 'revenue', + bar: { style: { fill: paint(shades[0]), cornerRadius: [16, 16, 0, 0] } }, + barMaxWidth: 64, + label: { + visible: true, + position: 'outside', + overlap: false, + style: { fill: paint(ink), fontSize: 20, fontWeight: 800 }, + formatMethod: value => '$' + value + 'k' + }, + axes: [axis('bottom'), { orient: 'left', visible: false, max: 45 }] +}; +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/circular-network.md b/docs/assets/examples/en/lieflat-charts/circular-network.md new file mode 100644 index 0000000000..72be41b777 --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/circular-network.md @@ -0,0 +1,311 @@ +--- +category: examples +group: lieflat charts +title: G6 · Circular network +keywords: lieflat,G6,editorial,theme +order: 39 +cover: /vchart/preview/lieflat-charts-circular-network.png +--- + +# G6 · Circular network + +Who works with whom · hover neighbors · click to pin. + +A VChart implementation of G6 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const nodes = Array.from({ length: 12 }, (_, i) => { + const angle = (i / 12) * Math.PI * 2; + return { id: i, name: 'N' + (i + 1), x: 320 + Math.cos(angle) * 143, y: 235 + Math.sin(angle) * 143 }; +}); +const links = nodes.slice(1).map((node, i) => ({ source: i, target: node.id })); +for (let i = 0; i < 6; i++) links.push({ source: i, target: (i * 7 + 5) % nodes.length }); + +links.forEach(link => { + const a = nodes[link.source], + b = nodes[link.target]; + put( + 'path', + { + path: 'M' + a.x + ',' + a.y + ' Q320,235 ' + b.x + ',' + b.y, + stroke: muted, + strokeOpacity: 0.35, + lineWidth: 0.7, + fill: false, + source: link.source, + target: link.target + }, + a.name + ' ↔ ' + b.name + ); +}); +nodes.forEach(node => { + const degree = links.filter(link => link.source === node.id || link.target === node.id).length; + put( + 'symbol', + { + x: node.x, + y: node.y, + size: 4 + Math.sqrt(degree) * 2, + symbolType: 'circle', + fill: ink, + fillOpacity: 1, + nodeId: node.id + }, + node.name + ': ' + degree + ' connections' + ); + text(node.x, node.y - 14, node.name, 10, ink); +}); + +text(30, 28, 'Circular network', 19, ink, 'left', 700); +text(30, 52, 'Who works with whom · hover neighbors · click to pin', 10, muted, 'left'); +text(30, 408, 'G6 / CIRCULAR NETWORK / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + state: + type === 'path' + ? { linked: { strokeOpacity: 1 }, muted: { strokeOpacity: 0.04 } } + : type === 'symbol' + ? { muted: { fillOpacity: 0.12 } } + : {}, + animationUpdate: false, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +let focused = null; +let highlighted = null; +const pathMark = vchart + .getChart() + .getAllMarks() + .find(mark => mark.getUserId() === 'lieflat-path'); +const symbolMark = vchart + .getChart() + .getAllMarks() + .find(mark => mark.getUserId() === 'lieflat-symbol'); +const neighbors = nodes.map(node => new Set([node.id])); +links.forEach(({ source, target }) => { + neighbors[source].add(target); + neighbors[target].add(source); +}); +const highlight = id => { + if (id === highlighted) return; + highlighted = id; + // 高亮只切换 VRender 图元状态,不更新数据,不启动 update 动画。 + pathMark.getGraphics().forEach(graphic => { + const record = graphic.context.data[0]; + graphic.setStates(id === null ? [] : [record.source === id || record.target === id ? 'linked' : 'muted'], { + animate: false + }); + }); + symbolMark.getGraphics().forEach(graphic => { + const record = graphic.context.data[0]; + graphic.setStates(id === null || neighbors[id].has(record.nodeId) ? [] : ['muted'], { animate: false }); + }); + vchart.getStage().renderNextFrame(); +}; +vchart.on('pointermove', ({ datum }) => { + highlight(datum && datum.nodeId !== undefined ? datum.nodeId : focused); +}); +vchart.on('pointerleave', { source: 'chart' }, () => highlight(focused)); +vchart.on('click', { level: 'mark' }, ({ datum }) => { + if (datum && datum.nodeId !== undefined) { + focused = focused === datum.nodeId ? null : datum.nodeId; + highlight(focused); + } +}); + +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/cluster-field.md b/docs/assets/examples/en/lieflat-charts/cluster-field.md new file mode 100644 index 0000000000..7a325a81f5 --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/cluster-field.md @@ -0,0 +1,245 @@ +--- +category: examples +group: lieflat charts +title: L6 · Cluster field +keywords: lieflat,L6,editorial,theme +order: 22 +cover: /vchart/preview/lieflat-charts-cluster-field.png +--- + +# L6 · Cluster field + +The contributor field · 132 contributors · spokes = repository contributions. + +A VChart implementation of L6 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const path = (value, stroke = ink, lineWidth = 1, fill = false, detail = '') => + put('path', { path: value, stroke, lineWidth, fill }, detail); +const polar = (cx, cy, radius, angle) => [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)]; + +const groups = [ + { name: 'CORE', x: 245, y: 222, count: 80, radius: 112 }, + { name: 'SDK', x: 488, y: 143, count: 22, radius: 57 }, + { name: 'DOCS', x: 498, y: 314, count: 18, radius: 48 }, + { name: 'TOOLS', x: 89, y: 322, count: 12, radius: 38 } +]; +groups.forEach((group, g) => { + if (g) path('M245,222 Q355,245 ' + group.x + ',' + group.y, shades[1], 0.8); + for (let i = 0; i < group.count; i++) { + const angle = i * Math.PI * (3 - Math.sqrt(5)); + const radius = Math.sqrt((i + 1) / group.count) * group.radius; + const p = polar(group.x, group.y, radius, angle); + line(group.x, group.y, ...p, grid, 0.5); + dot(...p, 1.8 + noise(i, g) * 2.3, shades[g], group.name + ' / contributor ' + (i + 1)); + } + dot(group.x, group.y, 9, ink, group.name + ': ' + group.count + ' contributors'); + text(group.x, group.y + group.radius + 19, group.name, 11, ink, 'center', 700); +}); +text(30, 28, 'Cluster field', 19, ink, 'left', 700); +text(30, 52, 'The contributor field · 132 contributors · spokes = repository contributions', 10, muted, 'left'); +text(30, 408, 'L6 / CLUSTER FIELD / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/diverging-bars.md b/docs/assets/examples/en/lieflat-charts/diverging-bars.md new file mode 100644 index 0000000000..0fa0fc435f --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/diverging-bars.md @@ -0,0 +1,123 @@ +--- +category: examples +group: lieflat charts +title: G10 · Diverging bars +keywords: lieflat,G10,editorial,theme +order: 43 +cover: /vchart/preview/lieflat-charts-diverging-bars.png +--- + +# G10 · Diverging bars + +Where we gained, where we bled · net change by channel. + +A VChart implementation of G10 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Native entrance animation is enabled by default; animated stories start their player automatically. +- Native VChart series, axes and data binding. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const axis = orient => ({ + orient, + domainLine: { visible: false }, + tick: { visible: false }, + grid: { visible: orient === 'left', style: { lineDash: [2, 4] } }, + label: { style: { fontSize: 10, fontFamily: font } } +}); +const base = { + padding: { top: 24, right: 30, bottom: 30, left: 30 }, + title: { + text: 'Diverging bars', + subtext: 'Where we gained, where we bled · net change by channel', + textStyle: { fontFamily: font, fontSize: 19 }, + subtextStyle: { fontFamily: font, fontSize: 11 }, + padding: { bottom: 28 } + }, + animationAppear: { duration: 900, easing: 'quartOut' }, + legends: { visible: false } +}; + +const values = [ + ['SEARCH', 32], + ['PARTNERS', 24], + ['REFERRAL', 16], + ['SOCIAL', 8], + ['PAID', -12], + ['DIRECT', -21] +].map(([channel, change]) => ({ channel, change })); +const spec = { + animation: true, + ...base, + type: 'bar', + direction: 'horizontal', + data: [{ id: 'channels', values }], + xField: 'change', + yField: 'channel', + bar: { style: { fill: (d, ctx) => themeColor(shades[d.change >= 0 ? 0 : 1], ctx), cornerRadius: 8 } }, + barMaxWidth: 32, + label: { + visible: true, + position: 'outside', + style: { fill: paint(ink), fontWeight: 800 }, + formatMethod: value => (value > 0 ? '+' : '') + value + '%' + }, + axes: [ + { ...axis('bottom'), min: -30, max: 40 }, + { ...axis('left'), grid: { visible: false } } + ], + markLine: { x: 0, line: { style: { lineWidth: 1 } }, endSymbol: { visible: false } } +}; +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/dot-cascade.md b/docs/assets/examples/en/lieflat-charts/dot-cascade.md new file mode 100644 index 0000000000..e48ca410d7 --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/dot-cascade.md @@ -0,0 +1,259 @@ +--- +category: examples +group: lieflat charts +title: L2 · Dot cascade +keywords: lieflat,L2,editorial,theme +order: 18 +cover: /vchart/preview/lieflat-charts-dot-cascade.png +--- + +# L2 · Dot cascade + +What breaks, stacked and ranked · one small dot = 2 incidents. + +A VChart implementation of L2 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const causes = [ + 'DNS', + 'CDN', + 'QUOTA', + 'DISK', + 'CERT', + 'CACHE', + 'QUEUE', + 'LOCK', + 'OOM', + 'NET', + 'API', + 'CONFIG', + 'DB', + 'DEPLOY', + 'CODE', + 'HUMAN' +]; +const values = [2, 2, 4, 6, 6, 8, 10, 12, 14, 18, 22, 26, 30, 36, 44, 54]; +values.forEach((value, i) => { + const x = 55 + i * 35, + base = 337 - i * 5; + for (let k = 0; k < value / 2; k++) dot(x, base - k * 6, 1.7, sequential[3], causes[i] + ': ' + value + ' incidents'); + const y = base - (value / 2) * 6 - 4; + dot(x, y, 4.5, ink, causes[i] + ': ' + value + ' incidents'); + text(x, y - 15, value, 11, ink, 'center', 800); + put('text', { + x, + y: base + 18, + text: causes[i], + fill: muted, + fontFamily: font, + fontSize: 8, + angle: -90, + textAlign: 'right' + }); +}); +text(30, 28, 'Dot cascade', 19, ink, 'left', 700); +text(30, 52, 'What breaks, stacked and ranked · one small dot = 2 incidents', 10, muted, 'left'); +text(30, 408, 'L2 / DOT CASCADE / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/dot-heat.md b/docs/assets/examples/en/lieflat-charts/dot-heat.md new file mode 100644 index 0000000000..a211088449 --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/dot-heat.md @@ -0,0 +1,238 @@ +--- +category: examples +group: lieflat charts +title: F10 · Dot heat +keywords: lieflat,F10,editorial,theme +order: 9 +cover: /vchart/preview/lieflat-charts-dot-heat.png +--- + +# F10 · Dot heat + +When support gets loud · dot area = tickets · 7 days × 12 hours. + +A VChart implementation of F10 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const days = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN']; +days.forEach((day, d) => { + text(60, 105 + d * 38, day, 10, muted, 'right'); + for (let h = 0; h < 12; h++) { + const value = Math.round((d < 5 ? 38 : 12) * Math.exp(-Math.pow((h - 5) / 4, 2)) * noise(h, d + 10)); + dot( + 90 + h * 44, + 105 + d * 38, + Math.max(1, Math.sqrt(value) * 2), + value ? ink : grid, + day + ' ' + (h + 8) + ':00 / ' + value + ' tickets' + ); + } +}); +[0, 3, 6, 9, 11].forEach(h => text(90 + h * 44, 372, h + 8 + ':00')); + +text(30, 28, 'Dot heat', 19, ink, 'left', 700); +text(30, 52, 'When support gets loud · dot area = tickets · 7 days × 12 hours', 10, muted, 'left'); +text(30, 408, 'F10 / DOT HEAT / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/dot-waffle.md b/docs/assets/examples/en/lieflat-charts/dot-waffle.md new file mode 100644 index 0000000000..d81bc625c1 --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/dot-waffle.md @@ -0,0 +1,237 @@ +--- +category: examples +group: lieflat charts +title: G4 · Dot waffle +keywords: lieflat,G4,editorial,theme +order: 37 +cover: /vchart/preview/lieflat-charts-dot-waffle.png +--- + +# G4 · Dot waffle + +Where sign-ups come from · one dot = 1%. + +A VChart implementation of G4 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const channels = [ + ['SEARCH', 34], + ['REFERRAL', 27], + ['SOCIAL', 18], + ['PARTNERS', 12], + ['PAID', 9] +]; +let index = 0; +channels.forEach(([name, value], group) => { + for (let k = 0; k < value; k++, index++) + dot(95 + (index % 10) * 25, 105 + Math.floor(index / 10) * 27, 8, shades[group], name + ': ' + value + '%'); + dot(405, 120 + group * 51, 5, shades[group]); + text(420, 120 + group * 51, name, 11, muted, 'left'); + text(585, 120 + group * 51, value + '%', 22, ink, 'right', 800); +}); +text(30, 28, 'Dot waffle', 19, ink, 'left', 700); +text(30, 52, 'Where sign-ups come from · one dot = 1%', 10, muted, 'left'); +text(30, 408, 'G4 / DOT WAFFLE / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/dotty-matrix.md b/docs/assets/examples/en/lieflat-charts/dotty-matrix.md new file mode 100644 index 0000000000..bfe5b5c7a2 --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/dotty-matrix.md @@ -0,0 +1,243 @@ +--- +category: examples +group: lieflat charts +title: L8 · Dotty matrix +keywords: lieflat,L8,editorial,theme +order: 24 +cover: /vchart/preview/lieflat-charts-dotty-matrix.png +--- + +# L8 · Dotty matrix + +Four squads, stacked in space · dot area = tasks closed. + +A VChart implementation of L8 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const path = (value, stroke = ink, lineWidth = 1, fill = false, detail = '') => + put('path', { path: value, stroke, lineWidth, fill }, detail); + +const squads = ['PLATFORM', 'GROWTH', 'MOBILE', 'INFRA']; +squads.forEach((name, layer) => { + const y0 = 96 + layer * 67; + path('M180,' + y0 + ' L400,' + (y0 + 35) + ' L510,' + (y0 - 5) + ' L290,' + (y0 - 40) + ' Z', grid, 0.6); + for (let row = 0; row < 6; row++) + for (let col = 0; col < 6; col++) { + const value = Math.round(noise(row * 6 + col, layer + 1) * 25); + const x = 191 + col * 37 + row * 17, + y = y0 + col * 5.8 - row * 6.4; + dot( + x, + y, + 1 + Math.sqrt(value), + shades[3 - layer], + name + ' / week ' + (row * 6 + col + 1) + ': ' + value + ' tasks' + ); + } + text(165, y0 + 8, name, 10, shades[3 - layer], 'right', 600); +}); +text(30, 28, 'Dotty matrix', 19, ink, 'left', 700); +text(30, 52, 'Four squads, stacked in space · dot area = tasks closed', 10, muted, 'left'); +text(30, 408, 'L8 / DOTTY MATRIX / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/draw-in-counter.md b/docs/assets/examples/en/lieflat-charts/draw-in-counter.md new file mode 100644 index 0000000000..093eb3fb3a --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/draw-in-counter.md @@ -0,0 +1,160 @@ +--- +category: examples +group: lieflat charts +title: G18 · Draw-in and counter +keywords: lieflat,G18,editorial,theme +order: 51 +cover: /vchart/preview/lieflat-charts-draw-in-counter.png +--- + +# G18 · Draw-in and counter + +H1 revenue, drawn in one stroke · cumulative $k. + +A VChart implementation of G18 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Native entrance animation is enabled by default; animated stories start their player automatically. +- Native VChart series, axes and data binding. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const axis = orient => ({ + orient, + domainLine: { visible: false }, + tick: { visible: false }, + grid: { visible: orient === 'left', style: { lineDash: [2, 4] } }, + label: { style: { fontSize: 10, fontFamily: font } } +}); +const base = { + padding: { top: 24, right: 30, bottom: 30, left: 30 }, + title: { + text: 'Draw-in and counter', + subtext: 'H1 revenue, drawn in one stroke · cumulative $k', + textStyle: { fontFamily: font, fontSize: 19 }, + subtextStyle: { fontFamily: font, fontSize: 11 }, + padding: { bottom: 28 } + }, + animationAppear: { duration: 900, easing: 'quartOut' }, + legends: { visible: false } +}; + +const values = [18, 39, 67, 103, 148, 204].map((revenue, i) => ({ + month: ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN'][i], + revenue +})); +const frames = values.map((value, i) => ({ + data: [{ id: 'revenue', values: values.slice(0, i + 1) }], + title: { + ...base.title, + text: '$' + value.revenue + 'k', + subtext: 'H1 REVENUE / CUMULATIVE THROUGH ' + value.month, + textStyle: { ...base.title.textStyle, fontSize: 42 } + } +})); + +frames.forEach(frame => + frame.data.push({ + id: 'frameLabel', + values: [ + { + label: frame.title.text + } + ] + }) +); +const spec = { + animation: true, + ...base, + type: 'line', + data: frames[0].data, + xField: 'month', + yField: 'revenue', + line: { style: { stroke: paint(shades[0]), lineWidth: 3 } }, + point: { style: { fill: paint(shades[0]), size: 7 } }, + axes: [ + { ...axis('bottom'), domain: values.map(d => d.month) }, + { ...axis('left'), min: 0, max: 220 } + ], + animationAppear: { preset: 'clipIn', duration: 1100 }, + animationUpdate: { duration: 850 }, + + customMark: [ + { + type: 'text', + dataId: 'frameLabel', + style: { + x: 32, + y: 104, + text: d => d.label, + textAlign: 'left', + textBaseline: 'middle', + fontFamily: font, + fontSize: 38, + fill: paint(ink), + fontWeight: 800 + } + } + ], + title: { ...base.title, padding: { bottom: 76 } }, + player: { + type: 'discrete', + orient: 'bottom', + auto: true, + loop: true, + interval: 1600, + specs: frames + } +}; +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/dumbbell-queue.md b/docs/assets/examples/en/lieflat-charts/dumbbell-queue.md new file mode 100644 index 0000000000..2a650bf9ca --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/dumbbell-queue.md @@ -0,0 +1,242 @@ +--- +category: examples +group: lieflat charts +title: F12 · Dumbbell queue +keywords: lieflat,F12,editorial,theme +order: 11 +cover: /vchart/preview/lieflat-charts-dumbbell-queue.png +--- + +# F12 · Dumbbell queue + +Onboarding before and after · one bead = one minute saved. + +A VChart implementation of F12 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const rows = [ + ['SIGN-UP', 24, 12], + ['PROFILE', 19, 10], + ['IMPORT', 36, 21], + ['INVITE', 16, 8], + ['FIRST TASK', 29, 17] +]; +rows.forEach(([name, before, after], i) => { + const y = 112 + i * 52, + x = v => 125 + v * 11; + text(110, y, name, 10, muted, 'right'); + line(x(after), y, x(before), y, muted); + for (let v = after + 1; v < before; v++) dot(x(v), y, 1.6, muted, name + ': ' + (before - after) + ' minutes saved'); + dot(x(before), y, 5, paper, name + ': before ' + before + ' min', ink); + dot(x(after), y, 5, ink, name + ': after ' + after + ' min'); + text(x(before) + 14, y, before, 12, muted, 'left'); + text(x(after) - 14, y, after, 12, ink, 'right', 700); +}); +text(30, 28, 'Dumbbell queue', 19, ink, 'left', 700); +text(30, 52, 'Onboarding before and after · one bead = one minute saved', 10, muted, 'left'); +text(30, 408, 'F12 / DUMBBELL QUEUE / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/hairline-area.md b/docs/assets/examples/en/lieflat-charts/hairline-area.md new file mode 100644 index 0000000000..1a741cfa4d --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/hairline-area.md @@ -0,0 +1,238 @@ +--- +category: examples +group: lieflat charts +title: F3 · Hairline area +keywords: lieflat,F3,editorial,theme +order: 2 +cover: /vchart/preview/lieflat-charts-hairline-area.png +--- + +# F3 · Hairline area + +Concurrent users · one hairline = one day. + +A VChart implementation of F3 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const path = (value, stroke = ink, lineWidth = 1, fill = false, detail = '') => + put('path', { path: value, stroke, lineWidth, fill }, detail); + +const values = Array.from({ length: 45 }, (_, i) => + Math.round(35 + 24 * Math.sin(i / 7) + 10 * Math.sin(i / 2.8) + noise(i, 2) * 15) +); +const points = values.map((v, i) => [45 + i * 12.4, 340 - v * 3]); +points.forEach(([x, y], i) => line(x, 340, x, y, muted, 0.7, 'Day ' + (i + 1) + ': ' + values[i] + 'k users')); +path(points.map((p, i) => (i ? 'L' : 'M') + p.join(',')).join(' '), ink, 1.4); +line(40, 340, 600, 340); +[0, 22, 44].forEach((i, k) => text(points[i][0], 363, ['MAY', 'JUN', 'JUL'][k])); +const peak = values.indexOf(Math.max(...values)); +dot(...points[peak], 4, ink); +text(points[peak][0], points[peak][1] - 15, values[peak] + 'k', 14, ink, 'center', 800); + +text(30, 28, 'Hairline area', 19, ink, 'left', 700); +text(30, 52, 'Concurrent users · one hairline = one day', 10, muted, 'left'); +text(30, 408, 'F3 / HAIRLINE AREA / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/hairline-line.md b/docs/assets/examples/en/lieflat-charts/hairline-line.md new file mode 100644 index 0000000000..c54b8feafd --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/hairline-line.md @@ -0,0 +1,245 @@ +--- +category: examples +group: lieflat charts +title: F2 · Hairline line +keywords: lieflat,F2,editorial,theme +order: 1 +cover: /vchart/preview/lieflat-charts-hairline-line.png +--- + +# F2 · Hairline line + +Thirty days of sign-ups · hollow = weekend. + +A VChart implementation of F2 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const path = (value, stroke = ink, lineWidth = 1, fill = false, detail = '') => + put('path', { path: value, stroke, lineWidth, fill }, detail); + +const values = Array.from({ length: 30 }, (_, i) => + Math.round(45 + 23 * Math.sin(i / 4.6) + 14 * Math.sin(i / 2.1) + noise(i) * 12) +); +const points = values.map((v, i) => [50 + i * 18.5, 335 - v * 2.7]); +line(45, 345, 595, 345); +path(points.map((p, i) => (i ? 'L' : 'M') + p.join(',')).join(' ')); +points.forEach(([x, y], i) => { + line(x, 339, x, 345); + dot( + x, + y, + i === values.indexOf(Math.max(...values)) ? 5 : 2.5, + i % 7 > 4 ? paper : ink, + 'Day ' + (i + 1) + ': ' + values[i] + ' sign-ups', + ink + ); +}); +[0, 14, 29].forEach(i => text(points[i][0], 365, 'JUN ' + (i + 1))); + +text(30, 28, 'Hairline line', 19, ink, 'left', 700); +text(30, 52, 'Thirty days of sign-ups · hollow = weekend', 10, muted, 'left'); +text(30, 408, 'F2 / HAIRLINE LINE / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/hourglass-stream.md b/docs/assets/examples/en/lieflat-charts/hourglass-stream.md new file mode 100644 index 0000000000..72162f0009 --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/hourglass-stream.md @@ -0,0 +1,248 @@ +--- +category: examples +group: lieflat charts +title: L13 · Hourglass stream +keywords: lieflat,L13,editorial,theme +order: 29 +cover: /vchart/preview/lieflat-charts-hourglass-stream.png +--- + +# L13 · Hourglass stream + +The funnel, poured · stage width = people · one thread = 10 visitors. + +A VChart implementation of L13 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const path = (value, stroke = ink, lineWidth = 1, fill = false, detail = '') => + put('path', { path: value, stroke, lineWidth, fill }, detail); + +const stages = [ + ['VISITORS', 4200], + ['SIGN-UPS', 1900], + ['ACTIVATED', 960], + ['RETAINED', 540], + ['PAYING', 310] +]; +// 用相同单位贯穿阶段,避免把每段独立归一化后误画成等宽。 +stages.forEach(([name, value], i) => { + const y = 100 + i * 62; + text(570, y, name, 10, muted, 'right'); + text(90, y, value.toLocaleString(), 15, ink, 'right', 800); + line(110, y, 545, y, grid, 0.6); +}); +for (let unit = 0; unit < 420; unit++) { + let value = ''; + stages.forEach((stage, i) => { + if (unit >= stage[1] / 10) return; + const x = 320 + (unit - (stage[1] / 10 - 1) / 2) * 0.9; + value += (i ? ' L' : 'M') + x + ',' + (100 + i * 62); + }); + path(value, shades[2], 0.45, false, 'Visitor cohort ' + (unit + 1) + ' / 10 visitors'); +} + +text(30, 28, 'Hourglass stream', 19, ink, 'left', 700); +text(30, 52, 'The funnel, poured · stage width = people · one thread = 10 visitors', 10, muted, 'left'); +text(30, 408, 'L13 / HOURGLASS STREAM / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/hundred-field.md b/docs/assets/examples/en/lieflat-charts/hundred-field.md new file mode 100644 index 0000000000..189ee16a89 --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/hundred-field.md @@ -0,0 +1,241 @@ +--- +category: examples +group: lieflat charts +title: L14 · Hundred field +keywords: lieflat,L14,editorial,theme +order: 30 +cover: /vchart/preview/lieflat-charts-hundred-field.png +--- + +# L14 · Hundred field + +A hundred of us, four minds · one dot = one percentage point. + +A VChart implementation of L14 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const segments = [ + ['CHARGED', 41], + ['TORN', 35], + ['ADRIFT', 12], + ['AVERSE', 12] +]; +let offset = 0; +segments.forEach(([name, value], i) => { + for (let k = 0; k < value; k++) { + const index = offset + k, + row = Math.floor(index / 20), + col = index % 20; + dot(68 + col * 26, 124 + row * 37 + Math.sin(col / 4) * 10, 5, shades[i], name + ': ' + value + '%'); + } + dot(82 + i * 147, 340, 4, shades[i]); + text(94 + i * 147, 340, name, 10, muted, 'left'); + text(94 + i * 147, 365, value + '%', 22, ink, 'left', 800); + offset += value; +}); +text(30, 28, 'Hundred field', 19, ink, 'left', 700); +text(30, 52, 'A hundred of us, four minds · one dot = one percentage point', 10, muted, 'left'); +text(30, 408, 'L14 / HUNDRED FIELD / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/launch-fan.md b/docs/assets/examples/en/lieflat-charts/launch-fan.md new file mode 100644 index 0000000000..b079a3f309 --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/launch-fan.md @@ -0,0 +1,251 @@ +--- +category: examples +group: lieflat charts +title: L1 · Launch fan +keywords: lieflat,L1,editorial,theme +order: 17 +cover: /vchart/preview/lieflat-charts-launch-fan.png +--- + +# L1 · Launch fan + +Twelve features, fanned out · launch week × current MAU. + +A VChart implementation of L1 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const path = (value, stroke = ink, lineWidth = 1, fill = false, detail = '') => + put('path', { path: value, stroke, lineWidth, fill }, detail); +const polar = (cx, cy, radius, angle) => [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)]; + +const names = ['Editor', 'Boards', 'Docs', 'Chat', 'Flows', 'Vault', 'Pages', 'Sync', 'Grid', 'Views', 'Hub', 'Forms']; +const origin = [145, 360]; +[5, 10, 15, 20].forEach(week => { + const radius = 50 + week * 10; + const start = polar(...origin, radius, -Math.PI / 2); + const end = polar(...origin, radius, -0.08); + path('M' + start.join(',') + ' A' + radius + ',' + radius + ' 0 0 1 ' + end.join(','), grid, 0.8); +}); +names.forEach((name, i) => { + const angle = -Math.PI / 2 + (i / 11) * 1.46; + const launched = 3 + (i % 7), + active = 5 + Math.round(noise(i, 6) * 35); + const end = polar(...origin, 268, angle); + line(...origin, ...end, grid, 0.7); + for (let week = launched + 1; week <= 20; week++) + dot(...polar(...origin, 50 + week * 10, angle), 1.5, shades[1], name + ' / week ' + week); + dot( + ...polar(...origin, 50 + launched * 10, angle), + Math.sqrt(active) * 1.7, + ink, + name + ': launched W' + launched + ' / ' + active + 'k MAU' + ); + text(...polar(...origin, 287, angle), name, 10, muted); +}); +text(30, 28, 'Launch fan', 19, ink, 'left', 700); +text(30, 52, 'Twelve features, fanned out · launch week × current MAU', 10, muted, 'left'); +text(30, 408, 'L1 / LAUNCH FAN / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/nested-treemap.md b/docs/assets/examples/en/lieflat-charts/nested-treemap.md new file mode 100644 index 0000000000..ed4a6573d9 --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/nested-treemap.md @@ -0,0 +1,141 @@ +--- +category: examples +group: lieflat charts +title: F13 · Nested treemap +keywords: lieflat,F13,editorial,theme +order: 12 +cover: /vchart/preview/lieflat-charts-nested-treemap.png +--- + +# F13 · Nested treemap + +Where the work went · 1,280 hours · area = effort. + +A VChart implementation of F13 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Native entrance animation is enabled by default; animated stories start their player automatically. +- Native VChart series, axes and data binding. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const axis = orient => ({ + orient, + domainLine: { visible: false }, + tick: { visible: false }, + grid: { visible: orient === 'left', style: { lineDash: [2, 4] } }, + label: { style: { fontSize: 10, fontFamily: font } } +}); +const base = { + padding: { top: 24, right: 30, bottom: 30, left: 30 }, + title: { + text: 'Nested treemap', + subtext: 'Where the work went · 1,280 hours · area = effort', + textStyle: { fontFamily: font, fontSize: 19 }, + subtextStyle: { fontFamily: font, fontSize: 11 }, + padding: { bottom: 28 } + }, + animationAppear: { duration: 900, easing: 'quartOut' }, + legends: { visible: false } +}; + +const values = [ + { + name: 'CORE', + children: [ + { name: 'Editor', value: 320 }, + { name: 'Search', value: 180 }, + { name: 'Sync', value: 140 } + ] + }, + { + name: 'PLATFORM', + children: [ + { name: 'API', value: 220 }, + { name: 'SDK', value: 160 } + ] + }, + { + name: 'GROWTH', + children: [ + { name: 'Onboarding', value: 150 }, + { name: 'Billing', value: 110 } + ] + } +]; +const spec = { + animation: true, + ...base, + type: 'treemap', + data: [{ id: 'work', values }], + categoryField: 'name', + valueField: 'value', + gapWidth: 3, + nodePadding: 4, + leaf: { style: { stroke: paint(paper), lineWidth: 2 } }, + label: { + visible: true, + smartInvert: true, + style: { fontSize: 13 }, + formatMethod: (text, datum) => datum.name + '\n' + datum.value + ' h' + }, + nonLeaf: { visible: true }, + nonLeafLabel: { + visible: true, + position: 'top', + padding: 26, + style: { fill: paint(ink), fontSize: 12, textAlign: 'left', x: d => d.labelRect.x0 + 6 } + } +}; +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/paired-rungs.md b/docs/assets/examples/en/lieflat-charts/paired-rungs.md new file mode 100644 index 0000000000..1a2facae05 --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/paired-rungs.md @@ -0,0 +1,248 @@ +--- +category: examples +group: lieflat charts +title: F6 · Paired rungs +keywords: lieflat,F6,editorial,theme +order: 5 +cover: /vchart/preview/lieflat-charts-paired-rungs.png +--- + +# F6 · Paired rungs + +This year against last · one rung = $1k · left = 2025 · right = 2026. + +A VChart implementation of F6 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const plans = ['FREE', 'STARTER', 'PRO', 'TEAM']; +const pairs = [ + [26, 38], + [19, 27], + [12, 22], + [9, 16] +]; +pairs.forEach((pair, i) => { + pair.forEach((v, j) => { + const x = 105 + i * 140 + j * 35; + for (let k = 0; k < v; k++) + line( + x - 13, + 343 - k * 6, + x + 13, + 343 - k * 6, + j ? ink : shades[1], + 1, + plans[i] + ' / ' + (2025 + j) + ': $' + v + 'k' + ); + text(x, 328 - v * 6, v, 13, j ? ink : muted, 'center', 800); + }); + text(122 + i * 140, 367, plans[i], 11); +}); +line(55, 350, 595, 350); + +text(30, 28, 'Paired rungs', 19, ink, 'left', 700); +text(30, 52, 'This year against last · one rung = $1k · left = 2025 · right = 2026', 10, muted, 'left'); +text(30, 408, 'F6 / PAIRED RUNGS / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/pictorial-forest.md b/docs/assets/examples/en/lieflat-charts/pictorial-forest.md new file mode 100644 index 0000000000..d8e45c4d23 --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/pictorial-forest.md @@ -0,0 +1,238 @@ +--- +category: examples +group: lieflat charts +title: G5 · Pictorial forest +keywords: lieflat,G5,editorial,theme +order: 38 +cover: /vchart/preview/lieflat-charts-pictorial-forest.png +--- + +# G5 · Pictorial forest + +Trees planted, year by year · one tree symbol = 100 trees. + +A VChart implementation of G5 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const values = [6, 9, 12, 17, 22, 27]; +values.forEach((count, year) => { + const x = 87 + year * 94; + for (let k = 0; k < count; k++) { + const cx = x - 17 + (k % 3) * 17, + cy = 341 - Math.floor(k / 3) * 25; + put( + 'symbol', + { x: cx, y: cy - 5, size: 16, symbolType: 'triangleUp', fill: shades[year % 3] }, + 2021 + year + ': ' + count * 100 + ' trees' + ); + line(cx, cy, cx, cy + 7, shades[year % 3], 1.5); + } + text(x, 323 - Math.ceil(count / 3) * 25, (count * 100).toLocaleString(), 16, ink, 'center', 800); + text(x, 376, 2021 + year, 11); +}); +text(30, 28, 'Pictorial forest', 19, ink, 'left', 700); +text(30, 52, 'Trees planted, year by year · one tree symbol = 100 trees', 10, muted, 'left'); +text(30, 408, 'G5 / PICTORIAL FOREST / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/platform-tree.md b/docs/assets/examples/en/lieflat-charts/platform-tree.md new file mode 100644 index 0000000000..281c6fb86d --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/platform-tree.md @@ -0,0 +1,244 @@ +--- +category: examples +group: lieflat charts +title: G7 · Platform tree +keywords: lieflat,G7,editorial,theme +order: 40 +cover: /vchart/preview/lieflat-charts-platform-tree.png +--- + +# G7 · Platform tree + +Everything the platform ships · hierarchy, not quantitative size. + +A VChart implementation of G7 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const path = (value, stroke = ink, lineWidth = 1, fill = false, detail = '') => + put('path', { path: value, stroke, lineWidth, fill }, detail); + +const branches = [ + ['BUILD', ['Editor', 'Components', 'Templates']], + ['CONNECT', ['API', 'Webhooks', 'SDK']], + ['OPERATE', ['Analytics', 'Billing', 'Access']] +]; +branches.forEach(([name, children], i) => { + const y = 125 + i * 100; + path('M105,225 C170,225 185,' + y + ' 245,' + y, muted, 1.5); + dot(245, y, 5, ink, name); + text(245, y - 17, name, 11, ink, 'center', 700); + children.forEach((child, j) => { + const cy = y - 31 + j * 31; + path('M245,' + y + ' C335,' + y + ' 355,' + cy + ' 452,' + cy, grid, 1); + dot(452, cy, 3, muted, name + ' / ' + child); + text(465, cy, child, 11, ink, 'left'); + }); +}); +dot(105, 225, 9, ink); +text(105, 250, 'PLATFORM', 11, ink, 'center', 700); + +text(30, 28, 'Platform tree', 19, ink, 'left', 700); +text(30, 52, 'Everything the platform ships · hierarchy, not quantitative size', 10, muted, 'left'); +text(30, 408, 'G7 / PLATFORM TREE / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/radial-convergence.md b/docs/assets/examples/en/lieflat-charts/radial-convergence.md new file mode 100644 index 0000000000..db9dcbadce --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/radial-convergence.md @@ -0,0 +1,252 @@ +--- +category: examples +group: lieflat charts +title: L5 · Radial convergence +keywords: lieflat,L5,editorial,theme +order: 21 +cover: /vchart/preview/lieflat-charts-radial-convergence.png +--- + +# L5 · Radial convergence + +48 requests pull toward five themes · one rim node = one request. + +A VChart implementation of L5 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const path = (value, stroke = ink, lineWidth = 1, fill = false, detail = '') => + put('path', { path: value, stroke, lineWidth, fill }, detail); +const polar = (cx, cy, radius, angle) => [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)]; + +const themes = [ + ['PERF', 15], + ['INTEGRATIONS', 10], + ['PRICING', 9], + ['MOBILE', 8], + ['UX', 6] +]; +let index = 0; +themes.forEach(([name, count], group) => { + const centerAngle = ((index + count / 2) / 48) * Math.PI * 2 - Math.PI / 2; + const center = polar(320, 232, 55, centerAngle); + for (let i = 0; i < count; i++) { + const a = ((index + i) / 48) * Math.PI * 2 - Math.PI / 2; + const point = polar(320, 232, 139, a); + path( + 'M' + point.join(',') + ' Q320,232 ' + center.join(','), + shades[1], + 0.7, + false, + 'Request ' + (index + i + 1) + ' → ' + name + ); + dot(...point, 2.7, shades[group], 'Request ' + (index + i + 1) + ' → ' + name); + } + dot(...center, 5, ink, name + ': ' + count + ' requests'); + text(...polar(320, 232, 170, centerAngle), name + ' · ' + count, 10, ink, 'center', 700); + index += count; +}); +text(30, 28, 'Radial convergence', 19, ink, 'left', 700); +text(30, 52, '48 requests pull toward five themes · one rim node = one request', 10, muted, 'left'); +text(30, 408, 'L5 / RADIAL CONVERGENCE / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/radial-patchwork.md b/docs/assets/examples/en/lieflat-charts/radial-patchwork.md new file mode 100644 index 0000000000..2ce4cffc8a --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/radial-patchwork.md @@ -0,0 +1,253 @@ +--- +category: examples +group: lieflat charts +title: L10 · Radial patchwork +keywords: lieflat,L10,editorial,theme +order: 26 +cover: /vchart/preview/lieflat-charts-radial-patchwork.png +--- + +# L10 · Radial patchwork + +A quarter of deploys, overlaid · angle = time · radius = changes. + +A VChart implementation of L10 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const polar = (cx, cy, radius, angle) => [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)]; + +[50, 90, 130].forEach(r => { + put('arc', { + x: 320, + y: 230, + innerRadius: r, + outerRadius: r + 0.6, + startAngle: 0, + endAngle: Math.PI * 2, + fill: grid + }); +}); +for (let i = 0; i < 120; i++) { + const hour = noise(i, 3) * 24, + size = 10 + Math.round(noise(i, 17) * 100); + const angle = (hour / 24) * Math.PI * 2 - Math.PI / 2; + put( + 'arc', + { + x: 320, + y: 230, + innerRadius: 24, + outerRadius: 35 + size, + startAngle: angle, + endAngle: angle + 0.12, + fill: ink, + fillOpacity: 0.09 + }, + 'Deploy ' + (i + 1) + ' / ' + hour.toFixed(1) + 'h / ' + size + ' changes' + ); +} +[0, 6, 12, 18].forEach(h => text(...polar(320, 230, 155, (h / 24) * Math.PI * 2 - Math.PI / 2), h + ':00', 11)); + +text(30, 28, 'Radial patchwork', 19, ink, 'left', 700); +text(30, 52, 'A quarter of deploys, overlaid · angle = time · radius = changes', 10, muted, 'left'); +text(30, 408, 'L10 / RADIAL PATCHWORK / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/rank-strip.md b/docs/assets/examples/en/lieflat-charts/rank-strip.md new file mode 100644 index 0000000000..23734a52cd --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/rank-strip.md @@ -0,0 +1,116 @@ +--- +category: examples +group: lieflat charts +title: G21 · Rank strip +keywords: lieflat,G21,editorial,theme +order: 54 +cover: /vchart/preview/lieflat-charts-rank-strip.png +--- + +# G21 · Rank strip + +Flows climbs to the top · one is best · five quarterly snapshots. + +A VChart implementation of G21 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Native entrance animation is enabled by default; animated stories start their player automatically. +- Native VChart series, axes and data binding. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const axis = orient => ({ + orient, + domainLine: { visible: false }, + tick: { visible: false }, + grid: { visible: orient === 'left', style: { lineDash: [2, 4] } }, + label: { style: { fontSize: 10, fontFamily: font } } +}); +const base = { + padding: { top: 24, right: 30, bottom: 30, left: 30 }, + title: { + text: 'Rank strip', + subtext: 'Flows climbs to the top · one is best · five quarterly snapshots', + textStyle: { fontFamily: font, fontSize: 19 }, + subtextStyle: { fontFamily: font, fontSize: 11 }, + padding: { bottom: 28 } + }, + animationAppear: { duration: 900, easing: 'quartOut' }, + legends: { visible: false } +}; + +const names = ['EDITOR', 'FLOWS', 'DOCS', 'API', 'CHAT', 'SYNC']; +const ranks = [ + [1, 2, 3, 3, 2], + [5, 4, 2, 1, 1], + [2, 1, 1, 2, 3], + [3, 3, 4, 4, 4], + [4, 5, 5, 6, 5], + [6, 6, 6, 5, 6] +]; +const values = names.flatMap((name, i) => ranks[i].map((rank, period) => ({ name, period: 'Q' + (period + 1), rank }))); +const spec = { + animation: true, + ...base, + type: 'line', + data: [{ id: 'ranks', values }], + xField: 'period', + yField: 'rank', + seriesField: 'name', + line: { style: { lineWidth: d => (d.name === 'FLOWS' ? 3 : 1.3) } }, + point: { style: { size: 7 } }, + axes: [axis('bottom'), { ...axis('left'), min: 1, max: 6, inverse: true, tickCount: 6 }], + legends: { visible: true, orient: 'bottom', item: { label: { style: { fontSize: 10 } } } } +}; +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/ridgeline.md b/docs/assets/examples/en/lieflat-charts/ridgeline.md new file mode 100644 index 0000000000..eb79944d56 --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/ridgeline.md @@ -0,0 +1,247 @@ +--- +category: examples +group: lieflat charts +title: L19 · Ridgeline +keywords: lieflat,L19,editorial,theme +order: 34 +cover: /vchart/preview/lieflat-charts-ridgeline.png +--- + +# L19 · Ridgeline + +Five pipelines, five tempos · response time density · bandwidth = 1.2 h. + +A VChart implementation of L19 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const path = (value, stroke = ink, lineWidth = 1, fill = false, detail = '') => + put('path', { path: value, stroke, lineWidth, fill }, detail); + +const names = ['MOBILE', 'DESKTOP', 'API', 'IMPORTS', 'BATCH']; +const bandwidth = 1.2; +names.forEach((name, group) => { + const observations = Array.from({ length: 80 }, (_, i) => + Math.max(0, 3 + group * 3 + (noise(i, group + 4) + noise(i, group + 16) - 1) * (4 + group)) + ); + const density = x => + observations.reduce((sum, v) => sum + Math.exp(-0.5 * Math.pow((x - v) / bandwidth, 2)), 0) / + (observations.length * bandwidth * Math.sqrt(2 * Math.PI)); + const baseY = 136 + group * 54; + const points = Array.from({ length: 121 }, (_, i) => [130 + i * 3.6, baseY - density(i / 5) * 200]); + path( + 'M130,' + baseY + ' ' + points.map(p => 'L' + p.join(',')).join(' ') + ' L562,' + baseY + ' Z', + ink, + 0.8, + paper, + name + ': 80 response-time observations' + ); + observations.forEach(value => line(130 + value * 18, baseY, 130 + value * 18, baseY + 5, muted, 0.5)); + text(110, baseY - 12, name, 10, muted, 'right'); +}); +[0, 6, 12, 18, 24].forEach(value => text(130 + value * 18, 377, value + 'h', 10)); + +text(30, 28, 'Ridgeline', 19, ink, 'left', 700); +text(30, 52, 'Five pipelines, five tempos · response time density · bandwidth = 1.2 h', 10, muted, 'left'); +text(30, 408, 'L19 / RIDGELINE / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/rung-bars.md b/docs/assets/examples/en/lieflat-charts/rung-bars.md new file mode 100644 index 0000000000..6deeb50ad0 --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/rung-bars.md @@ -0,0 +1,237 @@ +--- +category: examples +group: lieflat charts +title: F1 · Rung bars +keywords: lieflat,F1,editorial,theme +order: 0 +cover: /vchart/preview/lieflat-charts-rung-bars.png +--- + +# F1 · Rung bars + +Revenue by plan · one rung = $1k MRR. + +A VChart implementation of F1 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const plans = ['FREE', 'STARTER', 'PRO', 'TEAM', 'SCALE', 'ENT']; +const values = [38, 27, 22, 16, 11, 7]; +line(55, 350, 590, 350); +values.forEach((value, i) => { + const x = 90 + i * 92; + for (let k = 0; k < value; k++) { + const y = 345 - k * 6; + line(x - 20, y, x + 20, y, shades[i], 1, plans[i] + ': $' + value + 'k MRR'); + if (k % 5 === 4) dot(x + 26, y, 0.9, muted); + } + text(x, 331 - value * 6, value, 15, ink, 'center', 800); + text(x, 368, plans[i], 10); +}); +text(30, 28, 'Rung bars', 19, ink, 'left', 700); +text(30, 52, 'Revenue by plan · one rung = $1k MRR', 10, muted, 'left'); +text(30, 408, 'F1 / RUNG BARS / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/rung-histogram.md b/docs/assets/examples/en/lieflat-charts/rung-histogram.md new file mode 100644 index 0000000000..3f25efb07d --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/rung-histogram.md @@ -0,0 +1,240 @@ +--- +category: examples +group: lieflat charts +title: F14 · Rung histogram +keywords: lieflat,F14,editorial,theme +order: 13 +cover: /vchart/preview/lieflat-charts-rung-histogram.png +--- + +# F14 · Rung histogram + +Most tickets resolve within six hours · 100 tickets · 2-hour bins. + +A VChart implementation of F14 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const counts = [12, 29, 27, 17, 9, 4, 2]; +counts.forEach((count, i) => { + const x = 82 + i * 78; + for (let k = 0; k < count; k++) + line( + x - 29, + 345 - k * 8, + x + 29, + 345 - k * 8, + i < 3 ? ink : muted, + 1, + i * 2 + '–' + (i * 2 + 2) + ' hours: ' + count + ' tickets' + ); + text(x, 329 - count * 8, count, 14, ink, 'center', 800); + text(x, 371, i * 2 + '–' + (i * 2 + 2) + 'h', 10); +}); +line(45, 351, 600, 351); + +text(30, 28, 'Rung histogram', 19, ink, 'left', 700); +text(30, 52, 'Most tickets resolve within six hours · 100 tickets · 2-hour bins', 10, muted, 'left'); +text(30, 408, 'F14 / RUNG HISTOGRAM / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/rung-waterfall.md b/docs/assets/examples/en/lieflat-charts/rung-waterfall.md new file mode 100644 index 0000000000..a242b69100 --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/rung-waterfall.md @@ -0,0 +1,242 @@ +--- +category: examples +group: lieflat charts +title: F9 · Rung waterfall +keywords: lieflat,F9,editorial,theme +order: 8 +cover: /vchart/preview/lieflat-charts-rung-waterfall.png +--- + +# F9 · Rung waterfall + +From gross to net · one rung = $1k. + +A VChart implementation of F9 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const steps = [ + ['GROSS', 48], + ['REFUNDS', -6], + ['FEES', -4], + ['COGS', -13], + ['OPEX', -9], + ['NET', 16] +]; +let total = 0; +steps.forEach(([name, v], i) => { + const end = i === 5 ? v : total + v; + const start = i === 0 || i === 5 ? 0 : total; + const x = 85 + i * 95; + for (let k = Math.min(start, end); k < Math.max(start, end); k++) + line(x - 24, 345 - k * 5, x + 24, 345 - k * 5, v < 0 ? muted : ink, 1, name + ': ' + v + 'k'); + if (i < 5) line(x + 24, 345 - end * 5, x + 71, 345 - end * 5); + text(x, 328 - Math.max(start, end) * 5, (v > 0 && i > 0 && i < 5 ? '+' : '') + v, 14, ink, 'center', 800); + text(x, 370, name, 10); + total = end; +}); +text(30, 28, 'Rung waterfall', 19, ink, 'left', 700); +text(30, 52, 'From gross to net · one rung = $1k', 10, muted, 'left'); +text(30, 408, 'F9 / RUNG WATERFALL / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/stacked-rungs.md b/docs/assets/examples/en/lieflat-charts/stacked-rungs.md new file mode 100644 index 0000000000..90410937e0 --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/stacked-rungs.md @@ -0,0 +1,250 @@ +--- +category: examples +group: lieflat charts +title: F7 · Stacked rungs +keywords: lieflat,F7,editorial,theme +order: 6 +cover: /vchart/preview/lieflat-charts-stacked-rungs.png +--- + +# F7 · Stacked rungs + +Regional revenue mix · CORE / API / ADD-ONS · one rung = $1k. + +A VChart implementation of F7 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const regions = ['AMERICAS', 'EMEA', 'APAC', 'OTHER']; +const values = [ + [22, 13, 8], + [17, 11, 7], + [12, 9, 5], + [8, 5, 3] +]; +values.forEach((parts, i) => { + const x = 100 + i * 145; + let total = 0; + parts.forEach((value, j) => { + for (let k = 0; k < value; k++) + line( + x - 28, + 345 - (total + k) * 5.6, + x + 28, + 345 - (total + k) * 5.6, + shades[j * 2], + 1.5, + regions[i] + ' / ' + ['CORE', 'API', 'ADD-ONS'][j] + ': $' + value + 'k' + ); + total += value; + }); + text(x, 334 - total * 5.6, total, 15, ink, 'center', 800); + text(x, 368, regions[i], 11); +}); +line(50, 351, 590, 351); + +text(30, 28, 'Stacked rungs', 19, ink, 'left', 700); +text(30, 52, 'Regional revenue mix · CORE / API / ADD-ONS · one rung = $1k', 10, muted, 'left'); +text(30, 408, 'F7 / STACKED RUNGS / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/stream-ribbon.md b/docs/assets/examples/en/lieflat-charts/stream-ribbon.md new file mode 100644 index 0000000000..049bab5a1b --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/stream-ribbon.md @@ -0,0 +1,118 @@ +--- +category: examples +group: lieflat charts +title: F16 · Stream ribbon +keywords: lieflat,F16,editorial,theme +order: 15 +cover: /vchart/preview/lieflat-charts-stream-ribbon.png +--- + +# F16 · Stream ribbon + +Three products trade the same river · 48 weeks · band width = active accounts. + +A VChart implementation of F16 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Native entrance animation is enabled by default; animated stories start their player automatically. +- Native VChart series, axes and data binding. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const axis = orient => ({ + orient, + domainLine: { visible: false }, + tick: { visible: false }, + grid: { visible: orient === 'left', style: { lineDash: [2, 4] } }, + label: { style: { fontSize: 10, fontFamily: font } } +}); +const base = { + padding: { top: 24, right: 30, bottom: 30, left: 30 }, + title: { + text: 'Stream ribbon', + subtext: 'Three products trade the same river · 48 weeks · band width = active accounts', + textStyle: { fontFamily: font, fontSize: 19 }, + subtextStyle: { fontFamily: font, fontSize: 11 }, + padding: { bottom: 28 } + }, + animationAppear: { duration: 900, easing: 'quartOut' }, + legends: { visible: false } +}; + +const values = []; +for (let week = 1; week <= 48; week++) { + ['EDITOR', 'FLOWS', 'API'].forEach((product, i) => + values.push({ week, product, value: Math.round(30 + 15 * Math.sin(week / 8 + i * 2) + (week * (i + 1)) / 5) }) + ); +} +const spec = { + animation: true, + ...base, + type: 'area', + data: [{ id: 'usage', values }], + xField: 'week', + yField: 'value', + seriesField: 'product', + stack: true, + stackOffsetSilhouette: true, + area: { style: { fillOpacity: 0.9 } }, + line: { style: { stroke: paint(paper), lineWidth: 1 } }, + point: { visible: false }, + axes: [ + { ...axis('bottom'), type: 'linear', tickCount: 6 }, + { orient: 'left', visible: false } + ], + legends: { visible: true, orient: 'bottom', item: { label: { style: { fontSize: 10 } } } } +}; +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/tick-donut.md b/docs/assets/examples/en/lieflat-charts/tick-donut.md new file mode 100644 index 0000000000..a9fe163942 --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/tick-donut.md @@ -0,0 +1,249 @@ +--- +category: examples +group: lieflat charts +title: F4 · Tick donut +keywords: lieflat,F4,editorial,theme +order: 3 +cover: /vchart/preview/lieflat-charts-tick-donut.png +--- + +# F4 · Tick donut + +Where traffic comes from · 100 countable ticks. + +A VChart implementation of F4 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const polar = (cx, cy, radius, angle) => [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)]; + +const sources = [ + ['ORGANIC', 37], + ['PAID', 28], + ['REFERRAL', 21], + ['SOCIAL', 14] +]; +let offset = 0; +sources.forEach(([name, value], i) => { + for (let k = 0; k < value; k++) { + const a = ((offset + k) / 100) * Math.PI * 2 - Math.PI / 2; + line( + ...polar(320, 230, 95, a), + ...polar(320, 230, 111 + noise(k, i) * 7, a), + shades[i], + 1.4, + name + ': ' + value + '%' + ); + } + const a = ((offset + value / 2) / 100) * Math.PI * 2 - Math.PI / 2; + const p = polar(320, 230, 151, a); + text(...p, name + ' · ' + value, 10, shades[i], 'center', 700); + offset += value; +}); +text(320, 225, '100', 36, ink, 'center', 800); +text(320, 253, 'ONE TICK = 1%', 10); + +text(30, 28, 'Tick donut', 19, ink, 'left', 700); +text(30, 52, 'Where traffic comes from · 100 countable ticks', 10, muted, 'left'); +text(30, 408, 'F4 / TICK DONUT / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/tick-gauge.md b/docs/assets/examples/en/lieflat-charts/tick-gauge.md new file mode 100644 index 0000000000..87b29ad7fb --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/tick-gauge.md @@ -0,0 +1,239 @@ +--- +category: examples +group: lieflat charts +title: F11 · Tick gauge +keywords: lieflat,F11,editorial,theme +order: 10 +cover: /vchart/preview/lieflat-charts-tick-gauge.png +--- + +# F11 · Tick gauge + +How far to the quarter’s goal · one tick = one percent. + +A VChart implementation of F11 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const polar = (cx, cy, radius, angle) => [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)]; + +const progress = 73; +for (let i = 0; i < 100; i++) { + const a = Math.PI + (i / 99) * Math.PI; + line( + ...polar(320, 295, 130, a), + ...polar(320, 295, 151, a), + i < progress ? ink : 'disableFontColor', + 1.7, + progress + '% of quarterly target' + ); +} +text(320, 262, progress + '%', 54, ink, 'center', 800); +text(320, 310, 'OF THE QUARTERLY GOAL', 11); +text(160, 329, '0'); +text(480, 329, '100'); + +text(30, 28, 'Tick gauge', 19, ink, 'left', 700); +text(30, 52, 'How far to the quarter’s goal · one tick = one percent', 10, muted, 'left'); +text(30, 408, 'F11 / TICK GAUGE / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/tick-rows.md b/docs/assets/examples/en/lieflat-charts/tick-rows.md new file mode 100644 index 0000000000..d3bef8f95d --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/tick-rows.md @@ -0,0 +1,236 @@ +--- +category: examples +group: lieflat charts +title: F5 · Tick rows +keywords: lieflat,F5,editorial,theme +order: 4 +cover: /vchart/preview/lieflat-charts-tick-rows.png +--- + +# F5 · Tick rows + +Six teams, shipped and counted · one tick = one release. + +A VChart implementation of F5 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const teams = ['PLATFORM', 'GROWTH', 'MOBILE', 'INFRA', 'ML', 'DESIGN']; +[34, 28, 22, 17, 11, 8].forEach((value, i) => { + const y = 108 + i * 47; + text(125, y, teams[i], 11, muted, 'right', 600); + line(145, y + 11, 565, y + 11); + for (let k = 0; k < value; k++) { + const x = 150 + k * 11.5; + line(x, y + 8, x, y - 9, ink, 1, teams[i] + ': ' + value + ' releases'); + if (k % 5 === 4) dot(x, y + 16, 1, muted); + } + text(162 + value * 11.5, y, value, 15, ink, 'left', 800); +}); +text(30, 28, 'Tick rows', 19, ink, 'left', 700); +text(30, 52, 'Six teams, shipped and counted · one tick = one release', 10, muted, 'left'); +text(30, 408, 'F5 / TICK ROWS / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/trend-lineage.md b/docs/assets/examples/en/lieflat-charts/trend-lineage.md new file mode 100644 index 0000000000..24c6e7ec3d --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/trend-lineage.md @@ -0,0 +1,253 @@ +--- +category: examples +group: lieflat charts +title: L11 · Trend lineage +keywords: lieflat,L11,editorial,theme +order: 27 +cover: /vchart/preview/lieflat-charts-trend-lineage.png +--- + +# L11 · Trend lineage + +Features rise, fall, come back · solid = launch · hollow = redesign · × = dormant. + +A VChart implementation of L11 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const features = [ + 'Dark mode', + 'Kanban', + 'Wikis', + 'Slash cmds', + 'Templates', + 'Inbox', + 'Live cursors', + 'AI drafts', + 'Voice notes', + 'Offline' +]; +features.forEach((name, row) => { + const start = 2016 + (row % 7), + end = row % 3 === 2 ? 2024 : 2026, + revised = Math.min(end, start + 3); + const y = 98 + row * 28, + x = year => 155 + (year - 2016) * 42; + text(137, y, name, 10, muted, 'right'); + line(x(start), y, x(end), y, row % 3 === 2 ? shades[1] : ink, 0.9); + dot(x(start), y, 3.5, ink, name + ': launched ' + start); + dot(x(revised), y, 4, paper, name + ': redesigned ' + revised, ink); + if (end < 2026) { + line(x(end) - 3, y - 3, x(end) + 3, y + 3, muted); + line(x(end) - 3, y + 3, x(end) + 3, y - 3, muted); + } else dot(x(end), y, 2, ink, name + ': active in 2026'); +}); +[2016, 2018, 2020, 2022, 2024, 2026].forEach(year => text(155 + (year - 2016) * 42, 385, year, 10)); + +text(30, 28, 'Trend lineage', 19, ink, 'left', 700); +text(30, 52, 'Features rise, fall, come back · solid = launch · hollow = redesign · × = dormant', 10, muted, 'left'); +text(30, 408, 'L11 / TREND LINEAGE / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/type-colonnade.md b/docs/assets/examples/en/lieflat-charts/type-colonnade.md new file mode 100644 index 0000000000..dc9932ca32 --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/type-colonnade.md @@ -0,0 +1,238 @@ +--- +category: examples +group: lieflat charts +title: L12 · Type colonnade +keywords: lieflat,L12,editorial,theme +order: 28 +cover: /vchart/preview/lieflat-charts-type-colonnade.png +--- + +# L12 · Type colonnade + +Forty-four repos, ten owners · every label is one repository. + +A VChart implementation of L12 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Entrance animation is enabled by default; custom graphics appear in sequence after their text labels. +- Data-bound `customMark` layers preserve the individual units. +- Uniform scaling preserves geometry; hover a mark to inspect its record. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const teams = ['PLATFORM', 'FRONTEND', 'BACKEND', 'DATA', 'INFRA', 'MOBILE', 'SECURITY', 'GROWTH', 'ML', 'DESIGN']; +const counts = [7, 6, 6, 5, 5, 4, 3, 3, 3, 2]; +let repo = 0; +teams.forEach((name, group) => { + const x = 58 + group * 58; + text(x, 102, counts[group], 23, ink, 'center', 800); + put('text', { x, y: 134, text: name, fill: muted, fontFamily: font, fontSize: 8, textAlign: 'left', angle: 90 }); + for (let i = 0; i < counts[group]; i++) { + const y = 226 + i * 19; + line(x, 195, x, y, grid, 0.8); + dot(x, y, 2, ink, name + ' owns repo-' + ++repo); + text(x + 5, y, 'repo-' + repo, 7, muted, 'left'); + } +}); +text(30, 28, 'Type colonnade', 19, ink, 'left', 700); +text(30, 52, 'Forty-four repos, ten owners · every label is one repository', 10, muted, 'left'); +text(30, 408, 'L12 / TYPE COLONNADE / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/us-choropleth.md b/docs/assets/examples/en/lieflat-charts/us-choropleth.md new file mode 100644 index 0000000000..1fb7b7052e --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/us-choropleth.md @@ -0,0 +1,184 @@ +--- +category: examples +group: lieflat charts +title: M1 · US choropleth +keywords: lieflat,M1,editorial,theme +order: 59 +cover: /vchart/preview/lieflat-charts-us-choropleth.png +--- + +# M1 · US choropleth + +Sign-ups across the states · geography is not quantity · neutral fill = no observation. + +A VChart implementation of M1 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Native entrance animation is enabled by default; animated stories start their player automatically. +- Native VChart series, axes and data binding. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const axis = orient => ({ + orient, + domainLine: { visible: false }, + tick: { visible: false }, + grid: { visible: orient === 'left', style: { lineDash: [2, 4] } }, + label: { style: { fontSize: 10, fontFamily: font } } +}); +const base = { + padding: { top: 24, right: 30, bottom: 30, left: 30 }, + title: { + text: 'US choropleth', + subtext: 'Sign-ups across the states · geography is not quantity · neutral fill = no observation', + textStyle: { fontFamily: font, fontSize: 19 }, + subtextStyle: { fontFamily: font, fontSize: 11 }, + padding: { bottom: 28 } + }, + animationAppear: { duration: 900, easing: 'quartOut' }, + legends: { visible: false } +}; + +// 与现有地图示例使用同一数据源。复制到业务项目时可改为自托管 GeoJSON。 +const response = await fetch('https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/geojson/usa.json'); +if (!response.ok) throw new Error('Unable to load USA GeoJSON: ' + response.status); +const geojson = await response.json(); +VChart.registerMap('lieflat-usa', geojson); +const volumes = { + California: 96, + 'New York': 78, + Texas: 72, + Washington: 68, + Massachusetts: 66, + Florida: 54, + Illinois: 49, + Colorado: 44, + Georgia: 41, + Virginia: 33, + Pennsylvania: 31, + 'North Carolina': 30, + 'New Jersey': 28, + Oregon: 22, + Ohio: 21, + Michigan: 19, + Arizona: 18, + Minnesota: 16, + Utah: 15, + Maryland: 14, + Tennessee: 13, + Wisconsin: 12, + Missouri: 11, + Indiana: 9, + Nevada: 8, + Connecticut: 7, + 'South Carolina': 6, + Alabama: 5, + Kentucky: 5, + Oklahoma: 4, + Iowa: 4, + Kansas: 3, + Arkansas: 3, + Louisiana: 3, + 'New Hampshire': 2, + Idaho: 2, + 'New Mexico': 2, + Hawaii: 2, + Maine: 1, + Nebraska: 1, + Alaska: 1 +}; +const values = Object.entries(volumes).map(([name, value]) => ({ name, value })); +const spec = { + animation: true, + ...base, + type: 'map', + map: 'lieflat-usa', + nameField: 'name', + valueField: 'value', + nameProperty: 'name', + tooltip: { + mark: { + title: { value: d => d.name }, + content: [{ key: 'Accounts (k)', value: d => (d.value == null ? 'No observation' : d.value) }] + } + }, + data: [{ id: 'states', values }], + region: [{ projection: { type: 'albersUsa' } }], + color: { type: 'linear', domain: [0, 100], clamp: true }, + area: { + style: { + fill: (datum, ctx) => (datum.value == null ? themeColor(grid, ctx) : ctx.globalScale('color', datum.value)), + stroke: paint(paper), + lineWidth: 1 + }, + state: { hover: { stroke: paint(shades[0]), lineWidth: 2 } } + }, + legends: { + visible: true, + type: 'color', + orient: 'bottom', + position: 'middle', + field: 'value', + title: { visible: true, text: 'SIGN-UPS / THOUSANDS' } + } +}; +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +// 布局前更新标准颜色比例尺,区域和连续图例共享同一条主题渐变。 +vchart.on('layoutStart', () => { + const ctx = { vchart }; + vchart + .getScale('color') + .range([themeColor(sequential[5], ctx), themeColor(shades[0], ctx)]) + .domain([0, 100]); +}); +vchart.renderSync(); + +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/en/lieflat-charts/world-choropleth.md b/docs/assets/examples/en/lieflat-charts/world-choropleth.md new file mode 100644 index 0000000000..b31dd7e66d --- /dev/null +++ b/docs/assets/examples/en/lieflat-charts/world-choropleth.md @@ -0,0 +1,178 @@ +--- +category: examples +group: lieflat charts +title: M2 · World choropleth +keywords: lieflat,M2,editorial,theme +order: 60 +cover: /vchart/preview/lieflat-charts-world-choropleth.png +--- + +# M2 · World choropleth + +Where the users are · monthly active accounts · drag to pan, wheel to zoom. + +A VChart implementation of M2 from [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts). All values are deterministic, simulated demo data. + +## Key options + +- Native entrance animation is enabled by default; animated stories start their player automatically. +- Native VChart series, axes and data binding. +- Background, text, data colors and tooltips follow the active VChart theme; categorical and sequential palettes are separate. + +## Demo + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const axis = orient => ({ + orient, + domainLine: { visible: false }, + tick: { visible: false }, + grid: { visible: orient === 'left', style: { lineDash: [2, 4] } }, + label: { style: { fontSize: 10, fontFamily: font } } +}); +const base = { + padding: { top: 24, right: 30, bottom: 30, left: 30 }, + title: { + text: 'World choropleth', + subtext: 'Where the users are · monthly active accounts · drag to pan, wheel to zoom', + textStyle: { fontFamily: font, fontSize: 19 }, + subtextStyle: { fontFamily: font, fontSize: 11 }, + padding: { bottom: 28 } + }, + animationAppear: { duration: 900, easing: 'quartOut' }, + legends: { visible: false } +}; + +const response = await fetch('https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/geojson/world.json'); +if (!response.ok) throw new Error('Unable to load world GeoJSON: ' + response.status); +const geojson = await response.json(); +VChart.registerMap('lieflat-world', geojson); +const volumes = { + 'United States of America': 95, + India: 62, + 'United Kingdom': 48, + Germany: 41, + Brazil: 39, + Canada: 34, + France: 30, + Australia: 28, + China: 26, + Japan: 26, + 'South Korea': 21, + Spain: 19, + Italy: 17, + Singapore: 16, + Indonesia: 15, + Mexico: 14, + Netherlands: 13, + Poland: 13, + Sweden: 12, + Ireland: 12, + Philippines: 11, + Turkey: 10, + Vietnam: 9, + 'South Africa': 9, + Denmark: 9, + Norway: 8, + Thailand: 8, + Malaysia: 8, + Argentina: 7, + Nigeria: 7, + Chile: 6, + Colombia: 6, + 'New Zealand': 6, + Egypt: 5, + Pakistan: 5, + Russia: 5 +}; +const values = Object.entries(volumes).map(([name, value]) => ({ name, value })); +const spec = { + animation: true, + ...base, + type: 'map', + map: 'lieflat-world', + nameField: 'name', + valueField: 'value', + nameProperty: 'name', + tooltip: { + mark: { + title: { value: d => d.name }, + content: [{ key: 'Accounts (k)', value: d => (d.value == null ? 'No observation' : d.value) }] + } + }, + data: [{ id: 'countries', values }], + region: [{ roam: true, projection: { type: 'equirectangular' } }], + color: { type: 'linear', domain: [0, 100], clamp: true }, + area: { + style: { + fill: (datum, ctx) => (datum.value == null ? themeColor(grid, ctx) : ctx.globalScale('color', datum.value)), + stroke: paint(paper), + lineWidth: 0.6 + }, + state: { hover: { stroke: paint(shades[0]), lineWidth: 1.5 } } + }, + legends: { + visible: true, + type: 'color', + orient: 'bottom', + position: 'middle', + field: 'value', + title: { visible: true, text: 'MONTHLY ACTIVE ACCOUNTS / THOUSANDS' } + } +}; +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +// 布局前更新标准颜色比例尺,区域和连续图例共享同一条主题渐变。 +vchart.on('layoutStart', () => { + const ctx = { vchart }; + vchart + .getScale('color') + .range([themeColor(sequential[5], ctx), themeColor(shades[0], ctx)]) + .domain([0, 100]); +}); +vchart.renderSync(); + +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/menu.json b/docs/assets/examples/menu.json index d499c70d49..b0efde5223 100644 --- a/docs/assets/examples/menu.json +++ b/docs/assets/examples/menu.json @@ -150,6 +150,316 @@ } ] }, + { + "path": "lieflat-charts", + "title": { + "zh": "Lieflat · 信息图风格", + "en": "Lieflat · Infographic styles" + }, + "children": [ + { + "path": "rung-bars", + "title": { + "zh": "F1 · 阶梯柱状图", + "en": "F1 · Rung bars" + } + }, + { + "path": "hairline-line", + "title": { + "zh": "F2 · 细线折线图", + "en": "F2 · Hairline line" + } + }, + { + "path": "hairline-area", + "title": { + "zh": "F3 · 细线面积图", + "en": "F3 · Hairline area" + } + }, + { + "path": "tick-donut", + "title": { + "zh": "F4 · 刻度环形图", + "en": "F4 · Tick donut" + } + }, + { + "path": "tick-rows", + "title": { + "zh": "F5 · 刻度条形图", + "en": "F5 · Tick rows" + } + }, + { + "path": "paired-rungs", + "title": { + "zh": "F6 · 并列阶梯柱图", + "en": "F6 · Paired rungs" + } + }, + { + "path": "stacked-rungs", + "title": { + "zh": "F7 · 堆叠阶梯柱图", + "en": "F7 · Stacked rungs" + } + }, + { + "path": "rung-waterfall", + "title": { + "zh": "F9 · 阶梯瀑布图", + "en": "F9 · Rung waterfall" + } + }, + { + "path": "dot-heat", + "title": { + "zh": "F10 · 点阵热力图", + "en": "F10 · Dot heat" + } + }, + { + "path": "tick-gauge", + "title": { + "zh": "F11 · 刻度仪表盘", + "en": "F11 · Tick gauge" + } + }, + { + "path": "dumbbell-queue", + "title": { + "zh": "F12 · 串珠哑铃图", + "en": "F12 · Dumbbell queue" + } + }, + { + "path": "nested-treemap", + "title": { + "zh": "F13 · 嵌套矩形树图", + "en": "F13 · Nested treemap" + } + }, + { + "path": "rung-histogram", + "title": { + "zh": "F14 · 阶梯直方图", + "en": "F14 · Rung histogram" + } + }, + { + "path": "stream-ribbon", + "title": { + "zh": "F16 · 河流面积图", + "en": "F16 · Stream ribbon" + } + }, + { + "path": "launch-fan", + "title": { + "zh": "L1 · 发布扇面图", + "en": "L1 · Launch fan" + } + }, + { + "path": "dot-cascade", + "title": { + "zh": "L2 · 点阵阶梯图", + "en": "L2 · Dot cascade" + } + }, + { + "path": "barcode-lollipop", + "title": { + "zh": "L3 · 日历条码棒棒糖图", + "en": "L3 · Barcode lollipop" + } + }, + { + "path": "arc-matrix", + "title": { + "zh": "L4 · 弧形气泡矩阵", + "en": "L4 · Arc matrix" + } + }, + { + "path": "radial-convergence", + "title": { + "zh": "L5 · 放射汇聚图", + "en": "L5 · Radial convergence" + } + }, + { + "path": "cluster-field", + "title": { + "zh": "L6 · 贡献者群岛图", + "en": "L6 · Cluster field" + } + }, + { + "path": "brand-spectrum", + "title": { + "zh": "L7 · 品牌双极量表", + "en": "L7 · Brand spectrum" + } + }, + { + "path": "dotty-matrix", + "title": { + "zh": "L8 · 等距点阵平面", + "en": "L8 · Dotty matrix" + } + }, + { + "path": "bubble-almanac", + "title": { + "zh": "L9 · 年度气泡年鉴", + "en": "L9 · Bubble almanac" + } + }, + { + "path": "radial-patchwork", + "title": { + "zh": "L10 · 部署放射叠层图", + "en": "L10 · Radial patchwork" + } + }, + { + "path": "trend-lineage", + "title": { + "zh": "L11 · 功能生命周期图", + "en": "L11 · Trend lineage" + } + }, + { + "path": "type-colonnade", + "title": { + "zh": "L12 · 仓库归属柱廊", + "en": "L12 · Type colonnade" + } + }, + { + "path": "hourglass-stream", + "title": { + "zh": "L13 · 细流漏斗图", + "en": "L13 · Hourglass stream" + } + }, + { + "path": "hundred-field", + "title": { + "zh": "L14 · 百点构成图", + "en": "L14 · Hundred field" + } + }, + { + "path": "ballot-tally", + "title": { + "zh": "L15 · 多选刻度计数图", + "en": "L15 · Ballot tally" + } + }, + { + "path": "calendar-heat", + "title": { + "zh": "L17 · 年度日历热力图", + "en": "L17 · Calendar heat" + } + }, + { + "path": "ridgeline", + "title": { + "zh": "L19 · 分布山峦图", + "en": "L19 · Ridgeline" + } + }, + { + "path": "chunky-bars", + "title": { + "zh": "G3 · 粗柱排名图", + "en": "G3 · Chunky bars" + } + }, + { + "path": "dot-waffle", + "title": { + "zh": "G4 · 圆点华夫图", + "en": "G4 · Dot waffle" + } + }, + { + "path": "pictorial-forest", + "title": { + "zh": "G5 · 植树象形柱图", + "en": "G5 · Pictorial forest" + } + }, + { + "path": "circular-network", + "title": { + "zh": "G6 · 环形关系图", + "en": "G6 · Circular network" + } + }, + { + "path": "platform-tree", + "title": { + "zh": "G7 · 横向产品树", + "en": "G7 · Platform tree" + } + }, + { + "path": "diverging-bars", + "title": { + "zh": "G10 · 正负发散条形图", + "en": "G10 · Diverging bars" + } + }, + { + "path": "bar-race", + "title": { + "zh": "G16 · 产品动态排名", + "en": "G16 · Bar race" + } + }, + { + "path": "draw-in-counter", + "title": { + "zh": "G18 · 描线增长与数字", + "en": "G18 · Draw-in and counter" + } + }, + { + "path": "rank-strip", + "title": { + "zh": "G21 · 静态排名轨迹", + "en": "G21 · Rank strip" + } + }, + { + "path": "aggregate-sankey", + "title": { + "zh": "G22 · 聚合流量桑基图", + "en": "G22 · Aggregate Sankey" + } + }, + { + "path": "us-choropleth", + "title": { + "zh": "M1 · 美国州级分级地图", + "en": "M1 · US choropleth" + } + }, + { + "path": "world-choropleth", + "title": { + "zh": "M2 · 世界分级地图", + "en": "M2 · World choropleth" + } + } + ] + }, { "path": "vscreen", "title": { diff --git a/docs/assets/examples/zh/lieflat-charts/aggregate-sankey.md b/docs/assets/examples/zh/lieflat-charts/aggregate-sankey.md new file mode 100644 index 0000000000..e3e85d59e7 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/aggregate-sankey.md @@ -0,0 +1,122 @@ +--- +category: examples +group: lieflat charts +title: G22 · 聚合流量桑基图 +keywords: lieflat,G22,editorial,theme +order: 55 +cover: /vchart/preview/lieflat-charts-aggregate-sankey.png +--- + +# G22 · 聚合流量桑基图 + +来源流入套餐的带宽表示账户数量,节点值由入流或出流求和。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 G22 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启原生入场动画;动态叙事示例的播放器自动播放。 +- 使用原生图表类型、坐标轴与数据绑定。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const axis = orient => ({ + orient, + domainLine: { visible: false }, + tick: { visible: false }, + grid: { visible: orient === 'left', style: { lineDash: [2, 4] } }, + label: { style: { fontSize: 10, fontFamily: font } } +}); +const base = { + padding: { top: 24, right: 30, bottom: 30, left: 30 }, + title: { + text: 'Aggregate Sankey', + subtext: 'Channels pour into plans · ribbon width = accounts', + textStyle: { fontFamily: font, fontSize: 19 }, + subtextStyle: { fontFamily: font, fontSize: 11 }, + padding: { bottom: 28 } + }, + animationAppear: { duration: 900, easing: 'quartOut' }, + legends: { visible: false } +}; + +const nodes = ['Search', 'Social', 'Partners', 'Referral', 'Free', 'Pro', 'Team'].map(name => ({ name })); +const links = [ + [0, 4, 180], + [0, 5, 120], + [0, 6, 60], + [1, 4, 140], + [1, 5, 55], + [1, 6, 25], + [2, 4, 60], + [2, 5, 85], + [2, 6, 105], + [3, 4, 90], + [3, 5, 65], + [3, 6, 40] +].map(([source, target, value]) => ({ source, target, value })); +const spec = { + animation: true, + ...base, + type: 'sankey', + data: [{ id: 'flow', values: [{ nodes, links }] }], + categoryField: 'name', + valueField: 'value', + nodeAlign: 'justify', + nodeWidth: 8, + nodeGap: 18, + node: { style: { fill: paint(shades[0]) } }, + link: { style: { fillOpacity: 0.35 }, state: { hover: { fillOpacity: 0.8 } } }, + label: { visible: true, style: { fill: paint(ink), fontSize: 12 } } +}; +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/arc-matrix.md b/docs/assets/examples/zh/lieflat-charts/arc-matrix.md new file mode 100644 index 0000000000..e1ea51f14d --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/arc-matrix.md @@ -0,0 +1,242 @@ +--- +category: examples +group: lieflat charts +title: L4 · 弧形气泡矩阵 +keywords: lieflat,L4,editorial,theme +order: 20 +cover: /vchart/preview/lieflat-charts-arc-matrix.png +--- + +# L4 · 弧形气泡矩阵 + +产品与城市的二维矩阵沿弧线排布,圆面积表示付费账户数量。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 L4 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const path = (value, stroke = ink, lineWidth = 1, fill = false, detail = '') => + put('path', { path: value, stroke, lineWidth, fill }, detail); + +const products = ['Editor', 'Boards', 'Docs', 'Flows', 'Chat', 'Vault', 'Pages', 'Sync']; +const cities = ['SF', 'NYC', 'LON', 'BER', 'TOK', 'SYD', 'SIN', 'PAR', 'AMS', 'TOR', 'SEO', 'SAO']; +products.forEach((name, row) => { + const points = cities.map((city, col) => [115 + col * 42, 105 + row * 30 + 20 * Math.sin((col / 11) * Math.PI)]); + path(points.map((p, i) => (i ? 'L' : 'M') + p.join(',')).join(' '), grid, 0.7); + text(98, points[0][1], name, 10, muted, 'right'); + cities.forEach((city, col) => { + const value = Math.round(noise(col, row + 11) * 100); + dot( + ...points[col], + Math.sqrt(value) * 0.85, + row % 2 ? muted : ink, + name + ' / ' + city + ': ' + value + ' accounts' + ); + }); +}); +cities.forEach((city, i) => text(115 + i * 42, 375, city, 9)); + +text(30, 28, 'Arc matrix', 19, ink, 'left', 700); +text(30, 52, 'Eight products in twelve cities · bubble area = paying accounts', 10, muted, 'left'); +text(30, 408, 'L4 / ARC MATRIX / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/ballot-tally.md b/docs/assets/examples/zh/lieflat-charts/ballot-tally.md new file mode 100644 index 0000000000..d104942c0a --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/ballot-tally.md @@ -0,0 +1,235 @@ +--- +category: examples +group: lieflat charts +title: L15 · 多选刻度计数图 +keywords: lieflat,L15,editorial,theme +order: 31 +cover: /vchart/preview/lieflat-charts-ballot-tally.png +--- + +# L15 · 多选刻度计数图 + +每项独立按 0–100% 编码;多选题的各项百分比不要求合计 100%。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 L15 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const answers = [ + ['SAME PAY, MORE WORK', 51], + ['UNSUSTAINABLE PACE', 46], + ['QUALITY SLIPPING', 41], + ['REPLACED OUTRIGHT', 22] +]; +answers.forEach(([name, value], row) => { + const y = 118 + row * 67; + text(48, y - 16, name, 11, ink, 'left', 600); + for (let i = 0; i < 100; i++) + line(48 + i * 5.05, y, 48 + i * 5.05, y + 15, i < value ? ink : grid, 1, name + ': ' + value + '%'); + text(584, y + 7, value + '%', 21, ink, 'center', 800); +}); +text(30, 28, 'Ballot tally', 19, ink, 'left', 700); +text(30, 52, 'What they fear, tick by tick · multiple responses · one tick = 1%', 10, muted, 'left'); +text(30, 408, 'L15 / BALLOT TALLY / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/bar-race.md b/docs/assets/examples/zh/lieflat-charts/bar-race.md new file mode 100644 index 0000000000..e267d03bd5 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/bar-race.md @@ -0,0 +1,166 @@ +--- +category: examples +group: lieflat charts +title: G16 · 产品动态排名 +keywords: lieflat,G16,editorial,theme +order: 49 +cover: /vchart/preview/lieflat-charts-bar-race.png +--- + +# G16 · 产品动态排名 + +原生播放器逐月更新数据,条形长度和分类顺序同时变化。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 G16 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启原生入场动画;动态叙事示例的播放器自动播放。 +- 使用原生图表类型、坐标轴与数据绑定。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const axis = orient => ({ + orient, + domainLine: { visible: false }, + tick: { visible: false }, + grid: { visible: orient === 'left', style: { lineDash: [2, 4] } }, + label: { style: { fontSize: 10, fontFamily: font } } +}); +const base = { + padding: { top: 24, right: 30, bottom: 30, left: 30 }, + title: { + text: 'Bar race', + subtext: 'Eight products race · monthly active accounts · play / pause below', + textStyle: { fontFamily: font, fontSize: 19 }, + subtextStyle: { fontFamily: font, fontSize: 11 }, + padding: { bottom: 28 } + }, + animationAppear: { duration: 900, easing: 'quartOut' }, + legends: { visible: false } +}; + +const products = ['EDITOR', 'FLOWS', 'DOCS', 'API', 'CHAT', 'SYNC', 'VAULT', 'BOARDS']; +const frames = Array.from({ length: 12 }, (_, month) => ({ + data: [ + { + id: 'race', + values: products + .map((product, i) => ({ + product, + users: Math.round(30 + i * 5 + month * (3 + (7 - i) * 1.5) + Math.sin(month + i) * 15) + })) + .sort((a, b) => b.users - a.users) + } + ], + title: { ...base.title, subtext: 'MONTH ' + (month + 1) + ' / 2026 · ACTIVE ACCOUNTS (k)' } +})); + +frames.forEach(frame => + frame.data.push({ + id: 'frameLabel', + values: [ + { + label: frame.title.subtext + } + ] + }) +); +const spec = { + animation: true, + ...base, + type: 'bar', + direction: 'horizontal', + data: frames[0].data, + xField: 'users', + yField: 'product', + seriesField: 'product', + dataKey: 'product', + color: { type: 'ordinal', domain: products }, + axes: [ + { ...axis('bottom'), min: 0, max: 200 }, + { ...axis('left'), grid: { visible: false } } + ], + bar: { style: { cornerRadius: 6 } }, + barMaxWidth: 25, + label: { visible: true, position: 'outside', style: { fill: paint(ink), fontWeight: 800 } }, + animationUpdate: { duration: 700, easing: 'cubicInOut' }, + + customMark: [ + { + type: 'text', + dataId: 'frameLabel', + style: { + x: 32, + y: 89, + text: d => d.label, + textAlign: 'left', + textBaseline: 'middle', + fontFamily: font, + fontSize: 12, + fill: paint(ink), + fontWeight: 600 + } + } + ], + title: { ...base.title, padding: { bottom: 45 } }, + player: { + type: 'discrete', + orient: 'bottom', + auto: true, + loop: true, + interval: 1500, + specs: frames + } +}; +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/barcode-lollipop.md b/docs/assets/examples/zh/lieflat-charts/barcode-lollipop.md new file mode 100644 index 0000000000..6e9fbb5652 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/barcode-lollipop.md @@ -0,0 +1,241 @@ +--- +category: examples +group: lieflat charts +title: L3 · 日历条码棒棒糖图 +keywords: lieflat,L3,editorial,theme +order: 19 +cover: /vchart/preview/lieflat-charts-barcode-lollipop.png +--- + +# L3 · 日历条码棒棒糖图 + +90 天逐日保留日历位置,圆点表示日峰值,周末为空心点。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 L3 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const values = Array.from({ length: 90 }, (_, i) => + Math.round(120 + 55 * Math.sin(i / 10) + 25 * Math.sin(i / 4) + noise(i, 7) * 20) +); +const peaks = [...values.keys()] + .sort((a, b) => values[b] - values[a]) + .filter((i, j, indices) => indices.slice(0, j).every(k => Math.abs(k - i) > 6)) + .slice(0, 3); +values.forEach((value, i) => { + const x = 44 + i * 6.2, + y = 350 - value; + line(x, 92, x, 350, grid, 0.6); + line(x, y, x, y + 24, ink, 0.8); + dot(x, y, peaks.includes(i) ? 4 : 2, i % 7 > 4 ? paper : ink, 'Day ' + (i + 1) + ': ' + value + 'k users', ink); + if (peaks.includes(i)) text(x, y - 13, value + 'k', 11, ink, 'center', 800); +}); +['APR', 'MAY', 'JUN'].forEach((m, i) => text(44 + i * 186, 374, m, 11, muted, 'left')); + +text(30, 28, 'Barcode lollipop', 19, ink, 'left', 700); +text(30, 52, 'Ninety days as a barcode · Apr–Jun · peak concurrent users', 10, muted, 'left'); +text(30, 408, 'L3 / BARCODE LOLLIPOP / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/brand-spectrum.md b/docs/assets/examples/zh/lieflat-charts/brand-spectrum.md new file mode 100644 index 0000000000..182b6a75a0 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/brand-spectrum.md @@ -0,0 +1,239 @@ +--- +category: examples +group: lieflat charts +title: L7 · 品牌双极量表 +keywords: lieflat,L7,editorial,theme +order: 23 +cover: /vchart/preview/lieflat-charts-brand-spectrum.png +--- + +# L7 · 品牌双极量表 + +每一行的两端都是有效的品牌特征,粗点表示本品牌,细点表示竞品。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 L7 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const traits = [ + ['FRIEND', 'AUTHORITY', 0.38], + ['SERIOUS', 'PLAYFUL', 0.62], + ['RELIABLE', 'RISK-TAKING', 0.27], + ['CONTEMPORARY', 'CLASSIC', 0.42] +]; +traits.forEach(([left, right, position], i) => { + const y = 121 + i * 66; + text(125, y, left, 10, muted, 'right'); + text(515, y, right, 10, muted, 'left'); + line(150, y, 490, y, grid); + for (let k = 0; k <= 20; k++) line(150 + k * 17, y - 3, 150 + k * 17, y + 3, grid); + for (let k = 0; k < 6; k++) dot(150 + noise(k, i + 22) * 340, y, 2.3, muted, 'Competitor ' + (k + 1)); + dot(150 + position * 340, y, 7, ink, left + ' ↔ ' + right + ': ' + Math.round(position * 100) + '/100'); +}); +text(30, 28, 'Brand spectrum', 19, ink, 'left', 700); +text(30, 52, 'Where the brand sits · customer panel · big dot = us', 10, muted, 'left'); +text(30, 408, 'L7 / BRAND SPECTRUM / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/bubble-almanac.md b/docs/assets/examples/zh/lieflat-charts/bubble-almanac.md new file mode 100644 index 0000000000..003c1dedf8 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/bubble-almanac.md @@ -0,0 +1,239 @@ +--- +category: examples +group: lieflat charts +title: L9 · 年度气泡年鉴 +keywords: lieflat,L9,editorial,theme +order: 25 +cover: /vchart/preview/lieflat-charts-bubble-almanac.png +--- + +# L9 · 年度气泡年鉴 + +逐年逐产品展示工单总量,内部圆点表示升级工单。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 L9 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const products = ['EDITOR', 'BOARDS', 'DOCS', 'CHAT', 'FLOWS', 'VAULT', 'PAGES', 'SYNC']; +products.forEach((name, row) => { + const y = 110 + row * 34; + text(91, y, name, 9, muted, 'right'); + line(109, y, 601, y); + for (let year = 0; year < 8; year++) { + const tickets = Math.round(20 + noise(year, row + 12) * 230), + escalations = Math.round(tickets * (0.06 + noise(year, row) * 0.15)); + const x = 127 + year * 64; + dot(x, y, Math.sqrt(tickets) * 1.05, shades[1], name + ' / ' + (2019 + year) + ': ' + tickets + ' tickets'); + dot(x, y, Math.sqrt(escalations) * 1.05, ink, name + ' / ' + (2019 + year) + ': ' + escalations + ' escalations'); + } +}); +for (let year = 0; year < 8; year++) text(127 + year * 64, 377, 2019 + year, 10); + +text(30, 28, 'Bubble almanac', 19, ink, 'left', 700); +text(30, 52, 'Eight years of tickets · bubble area = tickets · inner circle = escalations', 10, muted, 'left'); +text(30, 408, 'L9 / BUBBLE ALMANAC / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/calendar-heat.md b/docs/assets/examples/zh/lieflat-charts/calendar-heat.md new file mode 100644 index 0000000000..59926289a2 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/calendar-heat.md @@ -0,0 +1,251 @@ +--- +category: examples +group: lieflat charts +title: L17 · 年度日历热力图 +keywords: lieflat,L17,editorial,theme +order: 33 +cover: /vchart/preview/lieflat-charts-calendar-heat.png +--- + +# L17 · 年度日历热力图 + +使用真实日历日期布局全年 365 天;每格为一天,连续色阶表示部署次数。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 L17 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const rect = (x, y, width, height, fill = ink, detail = '', stroke = fill) => + put('rect', { x, y, width, height, fill, stroke, lineWidth: 0.6 }, detail); + +const start = Date.UTC(2025, 0, 1); +const offset = (new Date(start).getUTCDay() + 6) % 7; +const months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']; +for (let i = 0; i < 365; i++) { + const date = new Date(start + i * 86400000), + week = Math.floor((i + offset) / 7), + day = (i + offset) % 7; + const value = Math.floor(noise(i, 14) * (day < 5 ? 18 : 7)); + const x = 48 + week * 10.4, + y = 151 + day * 24; + rect( + x, + y, + 8.2, + 18, + value === 0 ? grid : sequential[5 - Math.min(5, Math.floor(value / 3))], + date.toISOString().slice(0, 10) + ': ' + value + ' deploys' + ); + if (date.getUTCDate() === 1) text(x, 130, months[date.getUTCMonth()], 9, muted, 'left'); +} +['MON', 'WED', 'FRI', 'SUN'].forEach((day, i) => text(38, 160 + i * 48, day, 8, muted, 'right')); +text(590, 348, 'LESS', 9, muted, 'right'); +sequential + .slice() + .reverse() + .forEach((shade, i) => rect(480 + i * 17, 363, 12, 12, shade)); +text(475, 369, '0', 9, muted, 'right'); +text(590, 369, '18', 9, muted, 'left'); + +text(30, 28, 'Calendar heat', 19, ink, 'left', 700); +text(30, 52, 'A year of deploys, day by day · 2025 · one cell = one calendar day', 10, muted, 'left'); +text(30, 408, 'L17 / CALENDAR HEAT / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/chunky-bars.md b/docs/assets/examples/zh/lieflat-charts/chunky-bars.md new file mode 100644 index 0000000000..30dac8477e --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/chunky-bars.md @@ -0,0 +1,115 @@ +--- +category: examples +group: lieflat charts +title: G3 · 粗柱排名图 +keywords: lieflat,G3,editorial,theme +order: 36 +cover: /vchart/preview/lieflat-charts-chunky-bars.png +--- + +# G3 · 粗柱排名图 + +少量套餐的收入排名,以粗柱、大数字和圆角突出高低。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 G3 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启原生入场动画;动态叙事示例的播放器自动播放。 +- 使用原生图表类型、坐标轴与数据绑定。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const axis = orient => ({ + orient, + domainLine: { visible: false }, + tick: { visible: false }, + grid: { visible: orient === 'left', style: { lineDash: [2, 4] } }, + label: { style: { fontSize: 10, fontFamily: font } } +}); +const base = { + padding: { top: 24, right: 30, bottom: 30, left: 30 }, + title: { + text: 'Chunky bars', + subtext: 'Revenue by plan · monthly recurring revenue, $k', + textStyle: { fontFamily: font, fontSize: 19 }, + subtextStyle: { fontFamily: font, fontSize: 11 }, + padding: { bottom: 28 } + }, + animationAppear: { duration: 900, easing: 'quartOut' }, + legends: { visible: false } +}; + +const values = ['FREE', 'STARTER', 'PRO', 'TEAM', 'SCALE', 'ENT'].map((plan, i) => ({ + plan, + revenue: [38, 27, 22, 16, 11, 7][i] +})); +const spec = { + animation: true, + ...base, + type: 'bar', + data: [{ id: 'plans', values }], + xField: 'plan', + yField: 'revenue', + bar: { style: { fill: paint(shades[0]), cornerRadius: [16, 16, 0, 0] } }, + barMaxWidth: 64, + label: { + visible: true, + position: 'outside', + overlap: false, + style: { fill: paint(ink), fontSize: 20, fontWeight: 800 }, + formatMethod: value => '$' + value + 'k' + }, + axes: [axis('bottom'), { orient: 'left', visible: false, max: 45 }] +}; +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/circular-network.md b/docs/assets/examples/zh/lieflat-charts/circular-network.md new file mode 100644 index 0000000000..a36b3ede8f --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/circular-network.md @@ -0,0 +1,311 @@ +--- +category: examples +group: lieflat charts +title: G6 · 环形关系图 +keywords: lieflat,G6,editorial,theme +order: 39 +cover: /vchart/preview/lieflat-charts-circular-network.png +--- + +# G6 · 环形关系图 + +12 个节点按圆周排列,悬停突出相邻节点,点击固定或取消选择。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 G6 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const nodes = Array.from({ length: 12 }, (_, i) => { + const angle = (i / 12) * Math.PI * 2; + return { id: i, name: 'N' + (i + 1), x: 320 + Math.cos(angle) * 143, y: 235 + Math.sin(angle) * 143 }; +}); +const links = nodes.slice(1).map((node, i) => ({ source: i, target: node.id })); +for (let i = 0; i < 6; i++) links.push({ source: i, target: (i * 7 + 5) % nodes.length }); + +links.forEach(link => { + const a = nodes[link.source], + b = nodes[link.target]; + put( + 'path', + { + path: 'M' + a.x + ',' + a.y + ' Q320,235 ' + b.x + ',' + b.y, + stroke: muted, + strokeOpacity: 0.35, + lineWidth: 0.7, + fill: false, + source: link.source, + target: link.target + }, + a.name + ' ↔ ' + b.name + ); +}); +nodes.forEach(node => { + const degree = links.filter(link => link.source === node.id || link.target === node.id).length; + put( + 'symbol', + { + x: node.x, + y: node.y, + size: 4 + Math.sqrt(degree) * 2, + symbolType: 'circle', + fill: ink, + fillOpacity: 1, + nodeId: node.id + }, + node.name + ': ' + degree + ' connections' + ); + text(node.x, node.y - 14, node.name, 10, ink); +}); + +text(30, 28, 'Circular network', 19, ink, 'left', 700); +text(30, 52, 'Who works with whom · hover neighbors · click to pin', 10, muted, 'left'); +text(30, 408, 'G6 / CIRCULAR NETWORK / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + state: + type === 'path' + ? { linked: { strokeOpacity: 1 }, muted: { strokeOpacity: 0.04 } } + : type === 'symbol' + ? { muted: { fillOpacity: 0.12 } } + : {}, + animationUpdate: false, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +let focused = null; +let highlighted = null; +const pathMark = vchart + .getChart() + .getAllMarks() + .find(mark => mark.getUserId() === 'lieflat-path'); +const symbolMark = vchart + .getChart() + .getAllMarks() + .find(mark => mark.getUserId() === 'lieflat-symbol'); +const neighbors = nodes.map(node => new Set([node.id])); +links.forEach(({ source, target }) => { + neighbors[source].add(target); + neighbors[target].add(source); +}); +const highlight = id => { + if (id === highlighted) return; + highlighted = id; + // 高亮只切换 VRender 图元状态,不更新数据,不启动 update 动画。 + pathMark.getGraphics().forEach(graphic => { + const record = graphic.context.data[0]; + graphic.setStates(id === null ? [] : [record.source === id || record.target === id ? 'linked' : 'muted'], { + animate: false + }); + }); + symbolMark.getGraphics().forEach(graphic => { + const record = graphic.context.data[0]; + graphic.setStates(id === null || neighbors[id].has(record.nodeId) ? [] : ['muted'], { animate: false }); + }); + vchart.getStage().renderNextFrame(); +}; +vchart.on('pointermove', ({ datum }) => { + highlight(datum && datum.nodeId !== undefined ? datum.nodeId : focused); +}); +vchart.on('pointerleave', { source: 'chart' }, () => highlight(focused)); +vchart.on('click', { level: 'mark' }, ({ datum }) => { + if (datum && datum.nodeId !== undefined) { + focused = focused === datum.nodeId ? null : datum.nodeId; + highlight(focused); + } +}); + +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/cluster-field.md b/docs/assets/examples/zh/lieflat-charts/cluster-field.md new file mode 100644 index 0000000000..5b8771bb52 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/cluster-field.md @@ -0,0 +1,245 @@ +--- +category: examples +group: lieflat charts +title: L6 · 贡献者群岛图 +keywords: lieflat,L6,editorial,theme +order: 22 +cover: /vchart/preview/lieflat-charts-cluster-field.png +--- + +# L6 · 贡献者群岛图 + +中心仓库与贡献者组成群落,跨群连线表示跨仓贡献。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 L6 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const path = (value, stroke = ink, lineWidth = 1, fill = false, detail = '') => + put('path', { path: value, stroke, lineWidth, fill }, detail); +const polar = (cx, cy, radius, angle) => [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)]; + +const groups = [ + { name: 'CORE', x: 245, y: 222, count: 80, radius: 112 }, + { name: 'SDK', x: 488, y: 143, count: 22, radius: 57 }, + { name: 'DOCS', x: 498, y: 314, count: 18, radius: 48 }, + { name: 'TOOLS', x: 89, y: 322, count: 12, radius: 38 } +]; +groups.forEach((group, g) => { + if (g) path('M245,222 Q355,245 ' + group.x + ',' + group.y, shades[1], 0.8); + for (let i = 0; i < group.count; i++) { + const angle = i * Math.PI * (3 - Math.sqrt(5)); + const radius = Math.sqrt((i + 1) / group.count) * group.radius; + const p = polar(group.x, group.y, radius, angle); + line(group.x, group.y, ...p, grid, 0.5); + dot(...p, 1.8 + noise(i, g) * 2.3, shades[g], group.name + ' / contributor ' + (i + 1)); + } + dot(group.x, group.y, 9, ink, group.name + ': ' + group.count + ' contributors'); + text(group.x, group.y + group.radius + 19, group.name, 11, ink, 'center', 700); +}); +text(30, 28, 'Cluster field', 19, ink, 'left', 700); +text(30, 52, 'The contributor field · 132 contributors · spokes = repository contributions', 10, muted, 'left'); +text(30, 408, 'L6 / CLUSTER FIELD / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/diverging-bars.md b/docs/assets/examples/zh/lieflat-charts/diverging-bars.md new file mode 100644 index 0000000000..e9cfd65010 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/diverging-bars.md @@ -0,0 +1,123 @@ +--- +category: examples +group: lieflat charts +title: G10 · 正负发散条形图 +keywords: lieflat,G10,editorial,theme +order: 43 +cover: /vchart/preview/lieflat-charts-diverging-bars.png +--- + +# G10 · 正负发散条形图 + +正向增长和负向流失以零基线分开,保持数值符号。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 G10 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启原生入场动画;动态叙事示例的播放器自动播放。 +- 使用原生图表类型、坐标轴与数据绑定。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const axis = orient => ({ + orient, + domainLine: { visible: false }, + tick: { visible: false }, + grid: { visible: orient === 'left', style: { lineDash: [2, 4] } }, + label: { style: { fontSize: 10, fontFamily: font } } +}); +const base = { + padding: { top: 24, right: 30, bottom: 30, left: 30 }, + title: { + text: 'Diverging bars', + subtext: 'Where we gained, where we bled · net change by channel', + textStyle: { fontFamily: font, fontSize: 19 }, + subtextStyle: { fontFamily: font, fontSize: 11 }, + padding: { bottom: 28 } + }, + animationAppear: { duration: 900, easing: 'quartOut' }, + legends: { visible: false } +}; + +const values = [ + ['SEARCH', 32], + ['PARTNERS', 24], + ['REFERRAL', 16], + ['SOCIAL', 8], + ['PAID', -12], + ['DIRECT', -21] +].map(([channel, change]) => ({ channel, change })); +const spec = { + animation: true, + ...base, + type: 'bar', + direction: 'horizontal', + data: [{ id: 'channels', values }], + xField: 'change', + yField: 'channel', + bar: { style: { fill: (d, ctx) => themeColor(shades[d.change >= 0 ? 0 : 1], ctx), cornerRadius: 8 } }, + barMaxWidth: 32, + label: { + visible: true, + position: 'outside', + style: { fill: paint(ink), fontWeight: 800 }, + formatMethod: value => (value > 0 ? '+' : '') + value + '%' + }, + axes: [ + { ...axis('bottom'), min: -30, max: 40 }, + { ...axis('left'), grid: { visible: false } } + ], + markLine: { x: 0, line: { style: { lineWidth: 1 } }, endSymbol: { visible: false } } +}; +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/dot-cascade.md b/docs/assets/examples/zh/lieflat-charts/dot-cascade.md new file mode 100644 index 0000000000..847403bddc --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/dot-cascade.md @@ -0,0 +1,259 @@ +--- +category: examples +group: lieflat charts +title: L2 · 点阵阶梯图 +keywords: lieflat,L2,editorial,theme +order: 18 +cover: /vchart/preview/lieflat-charts-dot-cascade.png +--- + +# L2 · 点阵阶梯图 + +一颗小点代表两次事故,顶部圆点与数字为类目总量。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 L2 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const causes = [ + 'DNS', + 'CDN', + 'QUOTA', + 'DISK', + 'CERT', + 'CACHE', + 'QUEUE', + 'LOCK', + 'OOM', + 'NET', + 'API', + 'CONFIG', + 'DB', + 'DEPLOY', + 'CODE', + 'HUMAN' +]; +const values = [2, 2, 4, 6, 6, 8, 10, 12, 14, 18, 22, 26, 30, 36, 44, 54]; +values.forEach((value, i) => { + const x = 55 + i * 35, + base = 337 - i * 5; + for (let k = 0; k < value / 2; k++) dot(x, base - k * 6, 1.7, sequential[3], causes[i] + ': ' + value + ' incidents'); + const y = base - (value / 2) * 6 - 4; + dot(x, y, 4.5, ink, causes[i] + ': ' + value + ' incidents'); + text(x, y - 15, value, 11, ink, 'center', 800); + put('text', { + x, + y: base + 18, + text: causes[i], + fill: muted, + fontFamily: font, + fontSize: 8, + angle: -90, + textAlign: 'right' + }); +}); +text(30, 28, 'Dot cascade', 19, ink, 'left', 700); +text(30, 52, 'What breaks, stacked and ranked · one small dot = 2 incidents', 10, muted, 'left'); +text(30, 408, 'L2 / DOT CASCADE / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/dot-heat.md b/docs/assets/examples/zh/lieflat-charts/dot-heat.md new file mode 100644 index 0000000000..1c413bd5d3 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/dot-heat.md @@ -0,0 +1,238 @@ +--- +category: examples +group: lieflat charts +title: F10 · 点阵热力图 +keywords: lieflat,F10,editorial,theme +order: 9 +cover: /vchart/preview/lieflat-charts-dot-heat.png +--- + +# F10 · 点阵热力图 + +点面积代表工单数量;零值保留网格位置。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 F10 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const days = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN']; +days.forEach((day, d) => { + text(60, 105 + d * 38, day, 10, muted, 'right'); + for (let h = 0; h < 12; h++) { + const value = Math.round((d < 5 ? 38 : 12) * Math.exp(-Math.pow((h - 5) / 4, 2)) * noise(h, d + 10)); + dot( + 90 + h * 44, + 105 + d * 38, + Math.max(1, Math.sqrt(value) * 2), + value ? ink : grid, + day + ' ' + (h + 8) + ':00 / ' + value + ' tickets' + ); + } +}); +[0, 3, 6, 9, 11].forEach(h => text(90 + h * 44, 372, h + 8 + ':00')); + +text(30, 28, 'Dot heat', 19, ink, 'left', 700); +text(30, 52, 'When support gets loud · dot area = tickets · 7 days × 12 hours', 10, muted, 'left'); +text(30, 408, 'F10 / DOT HEAT / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/dot-waffle.md b/docs/assets/examples/zh/lieflat-charts/dot-waffle.md new file mode 100644 index 0000000000..6f1a60417d --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/dot-waffle.md @@ -0,0 +1,237 @@ +--- +category: examples +group: lieflat charts +title: G4 · 圆点华夫图 +keywords: lieflat,G4,editorial,theme +order: 37 +cover: /vchart/preview/lieflat-charts-dot-waffle.png +--- + +# G4 · 圆点华夫图 + +100 个圆点按来源连续填充,点数对应百分比。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 G4 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const channels = [ + ['SEARCH', 34], + ['REFERRAL', 27], + ['SOCIAL', 18], + ['PARTNERS', 12], + ['PAID', 9] +]; +let index = 0; +channels.forEach(([name, value], group) => { + for (let k = 0; k < value; k++, index++) + dot(95 + (index % 10) * 25, 105 + Math.floor(index / 10) * 27, 8, shades[group], name + ': ' + value + '%'); + dot(405, 120 + group * 51, 5, shades[group]); + text(420, 120 + group * 51, name, 11, muted, 'left'); + text(585, 120 + group * 51, value + '%', 22, ink, 'right', 800); +}); +text(30, 28, 'Dot waffle', 19, ink, 'left', 700); +text(30, 52, 'Where sign-ups come from · one dot = 1%', 10, muted, 'left'); +text(30, 408, 'G4 / DOT WAFFLE / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/dotty-matrix.md b/docs/assets/examples/zh/lieflat-charts/dotty-matrix.md new file mode 100644 index 0000000000..3fbc668d86 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/dotty-matrix.md @@ -0,0 +1,243 @@ +--- +category: examples +group: lieflat charts +title: L8 · 等距点阵平面 +keywords: lieflat,L8,editorial,theme +order: 24 +cover: /vchart/preview/lieflat-charts-dotty-matrix.png +--- + +# L8 · 等距点阵平面 + +四个团队各占一层,6×6 周网格的点面积表示完成任务数。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 L8 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const path = (value, stroke = ink, lineWidth = 1, fill = false, detail = '') => + put('path', { path: value, stroke, lineWidth, fill }, detail); + +const squads = ['PLATFORM', 'GROWTH', 'MOBILE', 'INFRA']; +squads.forEach((name, layer) => { + const y0 = 96 + layer * 67; + path('M180,' + y0 + ' L400,' + (y0 + 35) + ' L510,' + (y0 - 5) + ' L290,' + (y0 - 40) + ' Z', grid, 0.6); + for (let row = 0; row < 6; row++) + for (let col = 0; col < 6; col++) { + const value = Math.round(noise(row * 6 + col, layer + 1) * 25); + const x = 191 + col * 37 + row * 17, + y = y0 + col * 5.8 - row * 6.4; + dot( + x, + y, + 1 + Math.sqrt(value), + shades[3 - layer], + name + ' / week ' + (row * 6 + col + 1) + ': ' + value + ' tasks' + ); + } + text(165, y0 + 8, name, 10, shades[3 - layer], 'right', 600); +}); +text(30, 28, 'Dotty matrix', 19, ink, 'left', 700); +text(30, 52, 'Four squads, stacked in space · dot area = tasks closed', 10, muted, 'left'); +text(30, 408, 'L8 / DOTTY MATRIX / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/draw-in-counter.md b/docs/assets/examples/zh/lieflat-charts/draw-in-counter.md new file mode 100644 index 0000000000..efea88ef73 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/draw-in-counter.md @@ -0,0 +1,160 @@ +--- +category: examples +group: lieflat charts +title: G18 · 描线增长与数字 +keywords: lieflat,G18,editorial,theme +order: 51 +cover: /vchart/preview/lieflat-charts-draw-in-counter.png +--- + +# G18 · 描线增长与数字 + +累计收入按月绘制折线,标题随播放器同步更新累计金额。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 G18 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启原生入场动画;动态叙事示例的播放器自动播放。 +- 使用原生图表类型、坐标轴与数据绑定。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const axis = orient => ({ + orient, + domainLine: { visible: false }, + tick: { visible: false }, + grid: { visible: orient === 'left', style: { lineDash: [2, 4] } }, + label: { style: { fontSize: 10, fontFamily: font } } +}); +const base = { + padding: { top: 24, right: 30, bottom: 30, left: 30 }, + title: { + text: 'Draw-in and counter', + subtext: 'H1 revenue, drawn in one stroke · cumulative $k', + textStyle: { fontFamily: font, fontSize: 19 }, + subtextStyle: { fontFamily: font, fontSize: 11 }, + padding: { bottom: 28 } + }, + animationAppear: { duration: 900, easing: 'quartOut' }, + legends: { visible: false } +}; + +const values = [18, 39, 67, 103, 148, 204].map((revenue, i) => ({ + month: ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN'][i], + revenue +})); +const frames = values.map((value, i) => ({ + data: [{ id: 'revenue', values: values.slice(0, i + 1) }], + title: { + ...base.title, + text: '$' + value.revenue + 'k', + subtext: 'H1 REVENUE / CUMULATIVE THROUGH ' + value.month, + textStyle: { ...base.title.textStyle, fontSize: 42 } + } +})); + +frames.forEach(frame => + frame.data.push({ + id: 'frameLabel', + values: [ + { + label: frame.title.text + } + ] + }) +); +const spec = { + animation: true, + ...base, + type: 'line', + data: frames[0].data, + xField: 'month', + yField: 'revenue', + line: { style: { stroke: paint(shades[0]), lineWidth: 3 } }, + point: { style: { fill: paint(shades[0]), size: 7 } }, + axes: [ + { ...axis('bottom'), domain: values.map(d => d.month) }, + { ...axis('left'), min: 0, max: 220 } + ], + animationAppear: { preset: 'clipIn', duration: 1100 }, + animationUpdate: { duration: 850 }, + + customMark: [ + { + type: 'text', + dataId: 'frameLabel', + style: { + x: 32, + y: 104, + text: d => d.label, + textAlign: 'left', + textBaseline: 'middle', + fontFamily: font, + fontSize: 38, + fill: paint(ink), + fontWeight: 800 + } + } + ], + title: { ...base.title, padding: { bottom: 76 } }, + player: { + type: 'discrete', + orient: 'bottom', + auto: true, + loop: true, + interval: 1600, + specs: frames + } +}; +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/dumbbell-queue.md b/docs/assets/examples/zh/lieflat-charts/dumbbell-queue.md new file mode 100644 index 0000000000..347ed0ad26 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/dumbbell-queue.md @@ -0,0 +1,242 @@ +--- +category: examples +group: lieflat charts +title: F12 · 串珠哑铃图 +keywords: lieflat,F12,editorial,theme +order: 11 +cover: /vchart/preview/lieflat-charts-dumbbell-queue.png +--- + +# F12 · 串珠哑铃图 + +空心点为改版前、实心点为改版后;每颗珠子表示节省一分钟。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 F12 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const rows = [ + ['SIGN-UP', 24, 12], + ['PROFILE', 19, 10], + ['IMPORT', 36, 21], + ['INVITE', 16, 8], + ['FIRST TASK', 29, 17] +]; +rows.forEach(([name, before, after], i) => { + const y = 112 + i * 52, + x = v => 125 + v * 11; + text(110, y, name, 10, muted, 'right'); + line(x(after), y, x(before), y, muted); + for (let v = after + 1; v < before; v++) dot(x(v), y, 1.6, muted, name + ': ' + (before - after) + ' minutes saved'); + dot(x(before), y, 5, paper, name + ': before ' + before + ' min', ink); + dot(x(after), y, 5, ink, name + ': after ' + after + ' min'); + text(x(before) + 14, y, before, 12, muted, 'left'); + text(x(after) - 14, y, after, 12, ink, 'right', 700); +}); +text(30, 28, 'Dumbbell queue', 19, ink, 'left', 700); +text(30, 52, 'Onboarding before and after · one bead = one minute saved', 10, muted, 'left'); +text(30, 408, 'F12 / DUMBBELL QUEUE / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/hairline-area.md b/docs/assets/examples/zh/lieflat-charts/hairline-area.md new file mode 100644 index 0000000000..6e750e3ac8 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/hairline-area.md @@ -0,0 +1,238 @@ +--- +category: examples +group: lieflat charts +title: F3 · 细线面积图 +keywords: lieflat,F3,editorial,theme +order: 2 +cover: /vchart/preview/lieflat-charts-hairline-area.png +--- + +# F3 · 细线面积图 + +45 个日读数,垂线从零延伸至当天峰值。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 F3 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const path = (value, stroke = ink, lineWidth = 1, fill = false, detail = '') => + put('path', { path: value, stroke, lineWidth, fill }, detail); + +const values = Array.from({ length: 45 }, (_, i) => + Math.round(35 + 24 * Math.sin(i / 7) + 10 * Math.sin(i / 2.8) + noise(i, 2) * 15) +); +const points = values.map((v, i) => [45 + i * 12.4, 340 - v * 3]); +points.forEach(([x, y], i) => line(x, 340, x, y, muted, 0.7, 'Day ' + (i + 1) + ': ' + values[i] + 'k users')); +path(points.map((p, i) => (i ? 'L' : 'M') + p.join(',')).join(' '), ink, 1.4); +line(40, 340, 600, 340); +[0, 22, 44].forEach((i, k) => text(points[i][0], 363, ['MAY', 'JUN', 'JUL'][k])); +const peak = values.indexOf(Math.max(...values)); +dot(...points[peak], 4, ink); +text(points[peak][0], points[peak][1] - 15, values[peak] + 'k', 14, ink, 'center', 800); + +text(30, 28, 'Hairline area', 19, ink, 'left', 700); +text(30, 52, 'Concurrent users · one hairline = one day', 10, muted, 'left'); +text(30, 408, 'F3 / HAIRLINE AREA / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/hairline-line.md b/docs/assets/examples/zh/lieflat-charts/hairline-line.md new file mode 100644 index 0000000000..59959704ab --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/hairline-line.md @@ -0,0 +1,245 @@ +--- +category: examples +group: lieflat charts +title: F2 · 细线折线图 +keywords: lieflat,F2,editorial,theme +order: 1 +cover: /vchart/preview/lieflat-charts-hairline-line.png +--- + +# F2 · 细线折线图 + +每天一个点,周末使用空心点。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 F2 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const path = (value, stroke = ink, lineWidth = 1, fill = false, detail = '') => + put('path', { path: value, stroke, lineWidth, fill }, detail); + +const values = Array.from({ length: 30 }, (_, i) => + Math.round(45 + 23 * Math.sin(i / 4.6) + 14 * Math.sin(i / 2.1) + noise(i) * 12) +); +const points = values.map((v, i) => [50 + i * 18.5, 335 - v * 2.7]); +line(45, 345, 595, 345); +path(points.map((p, i) => (i ? 'L' : 'M') + p.join(',')).join(' ')); +points.forEach(([x, y], i) => { + line(x, 339, x, 345); + dot( + x, + y, + i === values.indexOf(Math.max(...values)) ? 5 : 2.5, + i % 7 > 4 ? paper : ink, + 'Day ' + (i + 1) + ': ' + values[i] + ' sign-ups', + ink + ); +}); +[0, 14, 29].forEach(i => text(points[i][0], 365, 'JUN ' + (i + 1))); + +text(30, 28, 'Hairline line', 19, ink, 'left', 700); +text(30, 52, 'Thirty days of sign-ups · hollow = weekend', 10, muted, 'left'); +text(30, 408, 'F2 / HAIRLINE LINE / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/hourglass-stream.md b/docs/assets/examples/zh/lieflat-charts/hourglass-stream.md new file mode 100644 index 0000000000..f62a723f2a --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/hourglass-stream.md @@ -0,0 +1,248 @@ +--- +category: examples +group: lieflat charts +title: L13 · 细流漏斗图 +keywords: lieflat,L13,editorial,theme +order: 29 +cover: /vchart/preview/lieflat-charts-hourglass-stream.png +--- + +# L13 · 细流漏斗图 + +各阶段宽度与人数成正比,每条细流代表 10 位到访者,流在未转化阶段终止。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 L13 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const path = (value, stroke = ink, lineWidth = 1, fill = false, detail = '') => + put('path', { path: value, stroke, lineWidth, fill }, detail); + +const stages = [ + ['VISITORS', 4200], + ['SIGN-UPS', 1900], + ['ACTIVATED', 960], + ['RETAINED', 540], + ['PAYING', 310] +]; +// 用相同单位贯穿阶段,避免把每段独立归一化后误画成等宽。 +stages.forEach(([name, value], i) => { + const y = 100 + i * 62; + text(570, y, name, 10, muted, 'right'); + text(90, y, value.toLocaleString(), 15, ink, 'right', 800); + line(110, y, 545, y, grid, 0.6); +}); +for (let unit = 0; unit < 420; unit++) { + let value = ''; + stages.forEach((stage, i) => { + if (unit >= stage[1] / 10) return; + const x = 320 + (unit - (stage[1] / 10 - 1) / 2) * 0.9; + value += (i ? ' L' : 'M') + x + ',' + (100 + i * 62); + }); + path(value, shades[2], 0.45, false, 'Visitor cohort ' + (unit + 1) + ' / 10 visitors'); +} + +text(30, 28, 'Hourglass stream', 19, ink, 'left', 700); +text(30, 52, 'The funnel, poured · stage width = people · one thread = 10 visitors', 10, muted, 'left'); +text(30, 408, 'L13 / HOURGLASS STREAM / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/hundred-field.md b/docs/assets/examples/zh/lieflat-charts/hundred-field.md new file mode 100644 index 0000000000..a3fbc550e6 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/hundred-field.md @@ -0,0 +1,241 @@ +--- +category: examples +group: lieflat charts +title: L14 · 百点构成图 +keywords: lieflat,L14,editorial,theme +order: 30 +cover: /vchart/preview/lieflat-charts-hundred-field.png +--- + +# L14 · 百点构成图 + +100 个点拆成四类,点数直接对应百分比,不模拟额外个体。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 L14 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const segments = [ + ['CHARGED', 41], + ['TORN', 35], + ['ADRIFT', 12], + ['AVERSE', 12] +]; +let offset = 0; +segments.forEach(([name, value], i) => { + for (let k = 0; k < value; k++) { + const index = offset + k, + row = Math.floor(index / 20), + col = index % 20; + dot(68 + col * 26, 124 + row * 37 + Math.sin(col / 4) * 10, 5, shades[i], name + ': ' + value + '%'); + } + dot(82 + i * 147, 340, 4, shades[i]); + text(94 + i * 147, 340, name, 10, muted, 'left'); + text(94 + i * 147, 365, value + '%', 22, ink, 'left', 800); + offset += value; +}); +text(30, 28, 'Hundred field', 19, ink, 'left', 700); +text(30, 52, 'A hundred of us, four minds · one dot = one percentage point', 10, muted, 'left'); +text(30, 408, 'L14 / HUNDRED FIELD / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/launch-fan.md b/docs/assets/examples/zh/lieflat-charts/launch-fan.md new file mode 100644 index 0000000000..b1f06dcb09 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/launch-fan.md @@ -0,0 +1,251 @@ +--- +category: examples +group: lieflat charts +title: L1 · 发布扇面图 +keywords: lieflat,L1,editorial,theme +order: 17 +cover: /vchart/preview/lieflat-charts-launch-fan.png +--- + +# L1 · 发布扇面图 + +每条射线表示一个功能,大圆点表示发布周,点面积编码当前活跃量。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 L1 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const path = (value, stroke = ink, lineWidth = 1, fill = false, detail = '') => + put('path', { path: value, stroke, lineWidth, fill }, detail); +const polar = (cx, cy, radius, angle) => [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)]; + +const names = ['Editor', 'Boards', 'Docs', 'Chat', 'Flows', 'Vault', 'Pages', 'Sync', 'Grid', 'Views', 'Hub', 'Forms']; +const origin = [145, 360]; +[5, 10, 15, 20].forEach(week => { + const radius = 50 + week * 10; + const start = polar(...origin, radius, -Math.PI / 2); + const end = polar(...origin, radius, -0.08); + path('M' + start.join(',') + ' A' + radius + ',' + radius + ' 0 0 1 ' + end.join(','), grid, 0.8); +}); +names.forEach((name, i) => { + const angle = -Math.PI / 2 + (i / 11) * 1.46; + const launched = 3 + (i % 7), + active = 5 + Math.round(noise(i, 6) * 35); + const end = polar(...origin, 268, angle); + line(...origin, ...end, grid, 0.7); + for (let week = launched + 1; week <= 20; week++) + dot(...polar(...origin, 50 + week * 10, angle), 1.5, shades[1], name + ' / week ' + week); + dot( + ...polar(...origin, 50 + launched * 10, angle), + Math.sqrt(active) * 1.7, + ink, + name + ': launched W' + launched + ' / ' + active + 'k MAU' + ); + text(...polar(...origin, 287, angle), name, 10, muted); +}); +text(30, 28, 'Launch fan', 19, ink, 'left', 700); +text(30, 52, 'Twelve features, fanned out · launch week × current MAU', 10, muted, 'left'); +text(30, 408, 'L1 / LAUNCH FAN / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/nested-treemap.md b/docs/assets/examples/zh/lieflat-charts/nested-treemap.md new file mode 100644 index 0000000000..bc6179cf63 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/nested-treemap.md @@ -0,0 +1,141 @@ +--- +category: examples +group: lieflat charts +title: F13 · 嵌套矩形树图 +keywords: lieflat,F13,editorial,theme +order: 12 +cover: /vchart/preview/lieflat-charts-nested-treemap.png +--- + +# F13 · 嵌套矩形树图 + +矩形面积表示工时,分组保留产品层级。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 F13 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启原生入场动画;动态叙事示例的播放器自动播放。 +- 使用原生图表类型、坐标轴与数据绑定。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const axis = orient => ({ + orient, + domainLine: { visible: false }, + tick: { visible: false }, + grid: { visible: orient === 'left', style: { lineDash: [2, 4] } }, + label: { style: { fontSize: 10, fontFamily: font } } +}); +const base = { + padding: { top: 24, right: 30, bottom: 30, left: 30 }, + title: { + text: 'Nested treemap', + subtext: 'Where the work went · 1,280 hours · area = effort', + textStyle: { fontFamily: font, fontSize: 19 }, + subtextStyle: { fontFamily: font, fontSize: 11 }, + padding: { bottom: 28 } + }, + animationAppear: { duration: 900, easing: 'quartOut' }, + legends: { visible: false } +}; + +const values = [ + { + name: 'CORE', + children: [ + { name: 'Editor', value: 320 }, + { name: 'Search', value: 180 }, + { name: 'Sync', value: 140 } + ] + }, + { + name: 'PLATFORM', + children: [ + { name: 'API', value: 220 }, + { name: 'SDK', value: 160 } + ] + }, + { + name: 'GROWTH', + children: [ + { name: 'Onboarding', value: 150 }, + { name: 'Billing', value: 110 } + ] + } +]; +const spec = { + animation: true, + ...base, + type: 'treemap', + data: [{ id: 'work', values }], + categoryField: 'name', + valueField: 'value', + gapWidth: 3, + nodePadding: 4, + leaf: { style: { stroke: paint(paper), lineWidth: 2 } }, + label: { + visible: true, + smartInvert: true, + style: { fontSize: 13 }, + formatMethod: (text, datum) => datum.name + '\n' + datum.value + ' h' + }, + nonLeaf: { visible: true }, + nonLeafLabel: { + visible: true, + position: 'top', + padding: 26, + style: { fill: paint(ink), fontSize: 12, textAlign: 'left', x: d => d.labelRect.x0 + 6 } + } +}; +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/paired-rungs.md b/docs/assets/examples/zh/lieflat-charts/paired-rungs.md new file mode 100644 index 0000000000..0c14d0bfd3 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/paired-rungs.md @@ -0,0 +1,248 @@ +--- +category: examples +group: lieflat charts +title: F6 · 并列阶梯柱图 +keywords: lieflat,F6,editorial,theme +order: 5 +cover: /vchart/preview/lieflat-charts-paired-rungs.png +--- + +# F6 · 并列阶梯柱图 + +每个套餐两组横档,左侧为 2025,右侧为 2026。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 F6 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const plans = ['FREE', 'STARTER', 'PRO', 'TEAM']; +const pairs = [ + [26, 38], + [19, 27], + [12, 22], + [9, 16] +]; +pairs.forEach((pair, i) => { + pair.forEach((v, j) => { + const x = 105 + i * 140 + j * 35; + for (let k = 0; k < v; k++) + line( + x - 13, + 343 - k * 6, + x + 13, + 343 - k * 6, + j ? ink : shades[1], + 1, + plans[i] + ' / ' + (2025 + j) + ': $' + v + 'k' + ); + text(x, 328 - v * 6, v, 13, j ? ink : muted, 'center', 800); + }); + text(122 + i * 140, 367, plans[i], 11); +}); +line(55, 350, 595, 350); + +text(30, 28, 'Paired rungs', 19, ink, 'left', 700); +text(30, 52, 'This year against last · one rung = $1k · left = 2025 · right = 2026', 10, muted, 'left'); +text(30, 408, 'F6 / PAIRED RUNGS / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/pictorial-forest.md b/docs/assets/examples/zh/lieflat-charts/pictorial-forest.md new file mode 100644 index 0000000000..c37731ad0a --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/pictorial-forest.md @@ -0,0 +1,238 @@ +--- +category: examples +group: lieflat charts +title: G5 · 植树象形柱图 +keywords: lieflat,G5,editorial,theme +order: 38 +cover: /vchart/preview/lieflat-charts-pictorial-forest.png +--- + +# G5 · 植树象形柱图 + +一棵树表示 100 棵实际种植树木,逐年数量由符号数给出。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 G5 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const values = [6, 9, 12, 17, 22, 27]; +values.forEach((count, year) => { + const x = 87 + year * 94; + for (let k = 0; k < count; k++) { + const cx = x - 17 + (k % 3) * 17, + cy = 341 - Math.floor(k / 3) * 25; + put( + 'symbol', + { x: cx, y: cy - 5, size: 16, symbolType: 'triangleUp', fill: shades[year % 3] }, + 2021 + year + ': ' + count * 100 + ' trees' + ); + line(cx, cy, cx, cy + 7, shades[year % 3], 1.5); + } + text(x, 323 - Math.ceil(count / 3) * 25, (count * 100).toLocaleString(), 16, ink, 'center', 800); + text(x, 376, 2021 + year, 11); +}); +text(30, 28, 'Pictorial forest', 19, ink, 'left', 700); +text(30, 52, 'Trees planted, year by year · one tree symbol = 100 trees', 10, muted, 'left'); +text(30, 408, 'G5 / PICTORIAL FOREST / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/platform-tree.md b/docs/assets/examples/zh/lieflat-charts/platform-tree.md new file mode 100644 index 0000000000..5d09a4c0b7 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/platform-tree.md @@ -0,0 +1,244 @@ +--- +category: examples +group: lieflat charts +title: G7 · 横向产品树 +keywords: lieflat,G7,editorial,theme +order: 40 +cover: /vchart/preview/lieflat-charts-platform-tree.png +--- + +# G7 · 横向产品树 + +以三层从属关系展示平台、产品组和具体模块。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 G7 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const path = (value, stroke = ink, lineWidth = 1, fill = false, detail = '') => + put('path', { path: value, stroke, lineWidth, fill }, detail); + +const branches = [ + ['BUILD', ['Editor', 'Components', 'Templates']], + ['CONNECT', ['API', 'Webhooks', 'SDK']], + ['OPERATE', ['Analytics', 'Billing', 'Access']] +]; +branches.forEach(([name, children], i) => { + const y = 125 + i * 100; + path('M105,225 C170,225 185,' + y + ' 245,' + y, muted, 1.5); + dot(245, y, 5, ink, name); + text(245, y - 17, name, 11, ink, 'center', 700); + children.forEach((child, j) => { + const cy = y - 31 + j * 31; + path('M245,' + y + ' C335,' + y + ' 355,' + cy + ' 452,' + cy, grid, 1); + dot(452, cy, 3, muted, name + ' / ' + child); + text(465, cy, child, 11, ink, 'left'); + }); +}); +dot(105, 225, 9, ink); +text(105, 250, 'PLATFORM', 11, ink, 'center', 700); + +text(30, 28, 'Platform tree', 19, ink, 'left', 700); +text(30, 52, 'Everything the platform ships · hierarchy, not quantitative size', 10, muted, 'left'); +text(30, 408, 'G7 / PLATFORM TREE / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/radial-convergence.md b/docs/assets/examples/zh/lieflat-charts/radial-convergence.md new file mode 100644 index 0000000000..75718baddb --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/radial-convergence.md @@ -0,0 +1,252 @@ +--- +category: examples +group: lieflat charts +title: L5 · 放射汇聚图 +keywords: lieflat,L5,editorial,theme +order: 21 +cover: /vchart/preview/lieflat-charts-radial-convergence.png +--- + +# L5 · 放射汇聚图 + +48 个需求保留独立节点,连线汇入所属的五个主题。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 L5 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const path = (value, stroke = ink, lineWidth = 1, fill = false, detail = '') => + put('path', { path: value, stroke, lineWidth, fill }, detail); +const polar = (cx, cy, radius, angle) => [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)]; + +const themes = [ + ['PERF', 15], + ['INTEGRATIONS', 10], + ['PRICING', 9], + ['MOBILE', 8], + ['UX', 6] +]; +let index = 0; +themes.forEach(([name, count], group) => { + const centerAngle = ((index + count / 2) / 48) * Math.PI * 2 - Math.PI / 2; + const center = polar(320, 232, 55, centerAngle); + for (let i = 0; i < count; i++) { + const a = ((index + i) / 48) * Math.PI * 2 - Math.PI / 2; + const point = polar(320, 232, 139, a); + path( + 'M' + point.join(',') + ' Q320,232 ' + center.join(','), + shades[1], + 0.7, + false, + 'Request ' + (index + i + 1) + ' → ' + name + ); + dot(...point, 2.7, shades[group], 'Request ' + (index + i + 1) + ' → ' + name); + } + dot(...center, 5, ink, name + ': ' + count + ' requests'); + text(...polar(320, 232, 170, centerAngle), name + ' · ' + count, 10, ink, 'center', 700); + index += count; +}); +text(30, 28, 'Radial convergence', 19, ink, 'left', 700); +text(30, 52, '48 requests pull toward five themes · one rim node = one request', 10, muted, 'left'); +text(30, 408, 'L5 / RADIAL CONVERGENCE / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/radial-patchwork.md b/docs/assets/examples/zh/lieflat-charts/radial-patchwork.md new file mode 100644 index 0000000000..e6610abfe4 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/radial-patchwork.md @@ -0,0 +1,253 @@ +--- +category: examples +group: lieflat charts +title: L10 · 部署放射叠层图 +keywords: lieflat,L10,editorial,theme +order: 26 +cover: /vchart/preview/lieflat-charts-radial-patchwork.png +--- + +# L10 · 部署放射叠层图 + +角度对应一天中的时间,半径对应部署规模,透明度叠加体现密度。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 L10 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const polar = (cx, cy, radius, angle) => [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)]; + +[50, 90, 130].forEach(r => { + put('arc', { + x: 320, + y: 230, + innerRadius: r, + outerRadius: r + 0.6, + startAngle: 0, + endAngle: Math.PI * 2, + fill: grid + }); +}); +for (let i = 0; i < 120; i++) { + const hour = noise(i, 3) * 24, + size = 10 + Math.round(noise(i, 17) * 100); + const angle = (hour / 24) * Math.PI * 2 - Math.PI / 2; + put( + 'arc', + { + x: 320, + y: 230, + innerRadius: 24, + outerRadius: 35 + size, + startAngle: angle, + endAngle: angle + 0.12, + fill: ink, + fillOpacity: 0.09 + }, + 'Deploy ' + (i + 1) + ' / ' + hour.toFixed(1) + 'h / ' + size + ' changes' + ); +} +[0, 6, 12, 18].forEach(h => text(...polar(320, 230, 155, (h / 24) * Math.PI * 2 - Math.PI / 2), h + ':00', 11)); + +text(30, 28, 'Radial patchwork', 19, ink, 'left', 700); +text(30, 52, 'A quarter of deploys, overlaid · angle = time · radius = changes', 10, muted, 'left'); +text(30, 408, 'L10 / RADIAL PATCHWORK / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/rank-strip.md b/docs/assets/examples/zh/lieflat-charts/rank-strip.md new file mode 100644 index 0000000000..05ee7dbbef --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/rank-strip.md @@ -0,0 +1,116 @@ +--- +category: examples +group: lieflat charts +title: G21 · 静态排名轨迹 +keywords: lieflat,G21,editorial,theme +order: 54 +cover: /vchart/preview/lieflat-charts-rank-strip.png +--- + +# G21 · 静态排名轨迹 + +六个产品跨五期排名,每个时点的排名无重复,纵轴倒序显示第一名在顶端。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 G21 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启原生入场动画;动态叙事示例的播放器自动播放。 +- 使用原生图表类型、坐标轴与数据绑定。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const axis = orient => ({ + orient, + domainLine: { visible: false }, + tick: { visible: false }, + grid: { visible: orient === 'left', style: { lineDash: [2, 4] } }, + label: { style: { fontSize: 10, fontFamily: font } } +}); +const base = { + padding: { top: 24, right: 30, bottom: 30, left: 30 }, + title: { + text: 'Rank strip', + subtext: 'Flows climbs to the top · one is best · five quarterly snapshots', + textStyle: { fontFamily: font, fontSize: 19 }, + subtextStyle: { fontFamily: font, fontSize: 11 }, + padding: { bottom: 28 } + }, + animationAppear: { duration: 900, easing: 'quartOut' }, + legends: { visible: false } +}; + +const names = ['EDITOR', 'FLOWS', 'DOCS', 'API', 'CHAT', 'SYNC']; +const ranks = [ + [1, 2, 3, 3, 2], + [5, 4, 2, 1, 1], + [2, 1, 1, 2, 3], + [3, 3, 4, 4, 4], + [4, 5, 5, 6, 5], + [6, 6, 6, 5, 6] +]; +const values = names.flatMap((name, i) => ranks[i].map((rank, period) => ({ name, period: 'Q' + (period + 1), rank }))); +const spec = { + animation: true, + ...base, + type: 'line', + data: [{ id: 'ranks', values }], + xField: 'period', + yField: 'rank', + seriesField: 'name', + line: { style: { lineWidth: d => (d.name === 'FLOWS' ? 3 : 1.3) } }, + point: { style: { size: 7 } }, + axes: [axis('bottom'), { ...axis('left'), min: 1, max: 6, inverse: true, tickCount: 6 }], + legends: { visible: true, orient: 'bottom', item: { label: { style: { fontSize: 10 } } } } +}; +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/ridgeline.md b/docs/assets/examples/zh/lieflat-charts/ridgeline.md new file mode 100644 index 0000000000..dffc184c5b --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/ridgeline.md @@ -0,0 +1,247 @@ +--- +category: examples +group: lieflat charts +title: L19 · 分布山峦图 +keywords: lieflat,L19,editorial,theme +order: 34 +cover: /vchart/preview/lieflat-charts-ridgeline.png +--- + +# L19 · 分布山峦图 + +基于各组连续观测值计算高斯核密度,以轮廓比较分布。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 L19 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const path = (value, stroke = ink, lineWidth = 1, fill = false, detail = '') => + put('path', { path: value, stroke, lineWidth, fill }, detail); + +const names = ['MOBILE', 'DESKTOP', 'API', 'IMPORTS', 'BATCH']; +const bandwidth = 1.2; +names.forEach((name, group) => { + const observations = Array.from({ length: 80 }, (_, i) => + Math.max(0, 3 + group * 3 + (noise(i, group + 4) + noise(i, group + 16) - 1) * (4 + group)) + ); + const density = x => + observations.reduce((sum, v) => sum + Math.exp(-0.5 * Math.pow((x - v) / bandwidth, 2)), 0) / + (observations.length * bandwidth * Math.sqrt(2 * Math.PI)); + const baseY = 136 + group * 54; + const points = Array.from({ length: 121 }, (_, i) => [130 + i * 3.6, baseY - density(i / 5) * 200]); + path( + 'M130,' + baseY + ' ' + points.map(p => 'L' + p.join(',')).join(' ') + ' L562,' + baseY + ' Z', + ink, + 0.8, + paper, + name + ': 80 response-time observations' + ); + observations.forEach(value => line(130 + value * 18, baseY, 130 + value * 18, baseY + 5, muted, 0.5)); + text(110, baseY - 12, name, 10, muted, 'right'); +}); +[0, 6, 12, 18, 24].forEach(value => text(130 + value * 18, 377, value + 'h', 10)); + +text(30, 28, 'Ridgeline', 19, ink, 'left', 700); +text(30, 52, 'Five pipelines, five tempos · response time density · bandwidth = 1.2 h', 10, muted, 'left'); +text(30, 408, 'L19 / RIDGELINE / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/rung-bars.md b/docs/assets/examples/zh/lieflat-charts/rung-bars.md new file mode 100644 index 0000000000..73c80b84c1 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/rung-bars.md @@ -0,0 +1,237 @@ +--- +category: examples +group: lieflat charts +title: F1 · 阶梯柱状图 +keywords: lieflat,F1,editorial,theme +order: 0 +cover: /vchart/preview/lieflat-charts-rung-bars.png +--- + +# F1 · 阶梯柱状图 + +每一档代表 $1k 月度经常性收入。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 F1 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const plans = ['FREE', 'STARTER', 'PRO', 'TEAM', 'SCALE', 'ENT']; +const values = [38, 27, 22, 16, 11, 7]; +line(55, 350, 590, 350); +values.forEach((value, i) => { + const x = 90 + i * 92; + for (let k = 0; k < value; k++) { + const y = 345 - k * 6; + line(x - 20, y, x + 20, y, shades[i], 1, plans[i] + ': $' + value + 'k MRR'); + if (k % 5 === 4) dot(x + 26, y, 0.9, muted); + } + text(x, 331 - value * 6, value, 15, ink, 'center', 800); + text(x, 368, plans[i], 10); +}); +text(30, 28, 'Rung bars', 19, ink, 'left', 700); +text(30, 52, 'Revenue by plan · one rung = $1k MRR', 10, muted, 'left'); +text(30, 408, 'F1 / RUNG BARS / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/rung-histogram.md b/docs/assets/examples/zh/lieflat-charts/rung-histogram.md new file mode 100644 index 0000000000..67fc23d06e --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/rung-histogram.md @@ -0,0 +1,240 @@ +--- +category: examples +group: lieflat charts +title: F14 · 阶梯直方图 +keywords: lieflat,F14,editorial,theme +order: 13 +cover: /vchart/preview/lieflat-charts-rung-histogram.png +--- + +# F14 · 阶梯直方图 + +工单解决时长按两小时分箱,一档代表一个工单。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 F14 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const counts = [12, 29, 27, 17, 9, 4, 2]; +counts.forEach((count, i) => { + const x = 82 + i * 78; + for (let k = 0; k < count; k++) + line( + x - 29, + 345 - k * 8, + x + 29, + 345 - k * 8, + i < 3 ? ink : muted, + 1, + i * 2 + '–' + (i * 2 + 2) + ' hours: ' + count + ' tickets' + ); + text(x, 329 - count * 8, count, 14, ink, 'center', 800); + text(x, 371, i * 2 + '–' + (i * 2 + 2) + 'h', 10); +}); +line(45, 351, 600, 351); + +text(30, 28, 'Rung histogram', 19, ink, 'left', 700); +text(30, 52, 'Most tickets resolve within six hours · 100 tickets · 2-hour bins', 10, muted, 'left'); +text(30, 408, 'F14 / RUNG HISTOGRAM / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/rung-waterfall.md b/docs/assets/examples/zh/lieflat-charts/rung-waterfall.md new file mode 100644 index 0000000000..3af56b076d --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/rung-waterfall.md @@ -0,0 +1,242 @@ +--- +category: examples +group: lieflat charts +title: F9 · 阶梯瀑布图 +keywords: lieflat,F9,editorial,theme +order: 8 +cover: /vchart/preview/lieflat-charts-rung-waterfall.png +--- + +# F9 · 阶梯瀑布图 + +从总收入扣除成本,刻度的起止位置对应累计值。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 F9 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const steps = [ + ['GROSS', 48], + ['REFUNDS', -6], + ['FEES', -4], + ['COGS', -13], + ['OPEX', -9], + ['NET', 16] +]; +let total = 0; +steps.forEach(([name, v], i) => { + const end = i === 5 ? v : total + v; + const start = i === 0 || i === 5 ? 0 : total; + const x = 85 + i * 95; + for (let k = Math.min(start, end); k < Math.max(start, end); k++) + line(x - 24, 345 - k * 5, x + 24, 345 - k * 5, v < 0 ? muted : ink, 1, name + ': ' + v + 'k'); + if (i < 5) line(x + 24, 345 - end * 5, x + 71, 345 - end * 5); + text(x, 328 - Math.max(start, end) * 5, (v > 0 && i > 0 && i < 5 ? '+' : '') + v, 14, ink, 'center', 800); + text(x, 370, name, 10); + total = end; +}); +text(30, 28, 'Rung waterfall', 19, ink, 'left', 700); +text(30, 52, 'From gross to net · one rung = $1k', 10, muted, 'left'); +text(30, 408, 'F9 / RUNG WATERFALL / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/stacked-rungs.md b/docs/assets/examples/zh/lieflat-charts/stacked-rungs.md new file mode 100644 index 0000000000..92844fc71f --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/stacked-rungs.md @@ -0,0 +1,250 @@ +--- +category: examples +group: lieflat charts +title: F7 · 堆叠阶梯柱图 +keywords: lieflat,F7,editorial,theme +order: 6 +cover: /vchart/preview/lieflat-charts-stacked-rungs.png +--- + +# F7 · 堆叠阶梯柱图 + +按产品堆叠各地区收入,每档代表 $1k。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 F7 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const regions = ['AMERICAS', 'EMEA', 'APAC', 'OTHER']; +const values = [ + [22, 13, 8], + [17, 11, 7], + [12, 9, 5], + [8, 5, 3] +]; +values.forEach((parts, i) => { + const x = 100 + i * 145; + let total = 0; + parts.forEach((value, j) => { + for (let k = 0; k < value; k++) + line( + x - 28, + 345 - (total + k) * 5.6, + x + 28, + 345 - (total + k) * 5.6, + shades[j * 2], + 1.5, + regions[i] + ' / ' + ['CORE', 'API', 'ADD-ONS'][j] + ': $' + value + 'k' + ); + total += value; + }); + text(x, 334 - total * 5.6, total, 15, ink, 'center', 800); + text(x, 368, regions[i], 11); +}); +line(50, 351, 590, 351); + +text(30, 28, 'Stacked rungs', 19, ink, 'left', 700); +text(30, 52, 'Regional revenue mix · CORE / API / ADD-ONS · one rung = $1k', 10, muted, 'left'); +text(30, 408, 'F7 / STACKED RUNGS / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/stream-ribbon.md b/docs/assets/examples/zh/lieflat-charts/stream-ribbon.md new file mode 100644 index 0000000000..afd121bec3 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/stream-ribbon.md @@ -0,0 +1,118 @@ +--- +category: examples +group: lieflat charts +title: F16 · 河流面积图 +keywords: lieflat,F16,editorial,theme +order: 15 +cover: /vchart/preview/lieflat-charts-stream-ribbon.png +--- + +# F16 · 河流面积图 + +三组产品每周活跃账户以居中堆叠面积显示,保留总量变化。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 F16 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启原生入场动画;动态叙事示例的播放器自动播放。 +- 使用原生图表类型、坐标轴与数据绑定。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const axis = orient => ({ + orient, + domainLine: { visible: false }, + tick: { visible: false }, + grid: { visible: orient === 'left', style: { lineDash: [2, 4] } }, + label: { style: { fontSize: 10, fontFamily: font } } +}); +const base = { + padding: { top: 24, right: 30, bottom: 30, left: 30 }, + title: { + text: 'Stream ribbon', + subtext: 'Three products trade the same river · 48 weeks · band width = active accounts', + textStyle: { fontFamily: font, fontSize: 19 }, + subtextStyle: { fontFamily: font, fontSize: 11 }, + padding: { bottom: 28 } + }, + animationAppear: { duration: 900, easing: 'quartOut' }, + legends: { visible: false } +}; + +const values = []; +for (let week = 1; week <= 48; week++) { + ['EDITOR', 'FLOWS', 'API'].forEach((product, i) => + values.push({ week, product, value: Math.round(30 + 15 * Math.sin(week / 8 + i * 2) + (week * (i + 1)) / 5) }) + ); +} +const spec = { + animation: true, + ...base, + type: 'area', + data: [{ id: 'usage', values }], + xField: 'week', + yField: 'value', + seriesField: 'product', + stack: true, + stackOffsetSilhouette: true, + area: { style: { fillOpacity: 0.9 } }, + line: { style: { stroke: paint(paper), lineWidth: 1 } }, + point: { visible: false }, + axes: [ + { ...axis('bottom'), type: 'linear', tickCount: 6 }, + { orient: 'left', visible: false } + ], + legends: { visible: true, orient: 'bottom', item: { label: { style: { fontSize: 10 } } } } +}; +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); + +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/tick-donut.md b/docs/assets/examples/zh/lieflat-charts/tick-donut.md new file mode 100644 index 0000000000..63c8cb6eaf --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/tick-donut.md @@ -0,0 +1,249 @@ +--- +category: examples +group: lieflat charts +title: F4 · 刻度环形图 +keywords: lieflat,F4,editorial,theme +order: 3 +cover: /vchart/preview/lieflat-charts-tick-donut.png +--- + +# F4 · 刻度环形图 + +将 100% 构成拆成 100 根刻度,每根代表一个百分点。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 F4 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const polar = (cx, cy, radius, angle) => [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)]; + +const sources = [ + ['ORGANIC', 37], + ['PAID', 28], + ['REFERRAL', 21], + ['SOCIAL', 14] +]; +let offset = 0; +sources.forEach(([name, value], i) => { + for (let k = 0; k < value; k++) { + const a = ((offset + k) / 100) * Math.PI * 2 - Math.PI / 2; + line( + ...polar(320, 230, 95, a), + ...polar(320, 230, 111 + noise(k, i) * 7, a), + shades[i], + 1.4, + name + ': ' + value + '%' + ); + } + const a = ((offset + value / 2) / 100) * Math.PI * 2 - Math.PI / 2; + const p = polar(320, 230, 151, a); + text(...p, name + ' · ' + value, 10, shades[i], 'center', 700); + offset += value; +}); +text(320, 225, '100', 36, ink, 'center', 800); +text(320, 253, 'ONE TICK = 1%', 10); + +text(30, 28, 'Tick donut', 19, ink, 'left', 700); +text(30, 52, 'Where traffic comes from · 100 countable ticks', 10, muted, 'left'); +text(30, 408, 'F4 / TICK DONUT / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/tick-gauge.md b/docs/assets/examples/zh/lieflat-charts/tick-gauge.md new file mode 100644 index 0000000000..38242b5fa3 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/tick-gauge.md @@ -0,0 +1,239 @@ +--- +category: examples +group: lieflat charts +title: F11 · 刻度仪表盘 +keywords: lieflat,F11,editorial,theme +order: 10 +cover: /vchart/preview/lieflat-charts-tick-gauge.png +--- + +# F11 · 刻度仪表盘 + +100 根等距刻度表示目标完成率,已完成部分用主题强调色。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 F11 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); +const polar = (cx, cy, radius, angle) => [cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)]; + +const progress = 73; +for (let i = 0; i < 100; i++) { + const a = Math.PI + (i / 99) * Math.PI; + line( + ...polar(320, 295, 130, a), + ...polar(320, 295, 151, a), + i < progress ? ink : 'disableFontColor', + 1.7, + progress + '% of quarterly target' + ); +} +text(320, 262, progress + '%', 54, ink, 'center', 800); +text(320, 310, 'OF THE QUARTERLY GOAL', 11); +text(160, 329, '0'); +text(480, 329, '100'); + +text(30, 28, 'Tick gauge', 19, ink, 'left', 700); +text(30, 52, 'How far to the quarter’s goal · one tick = one percent', 10, muted, 'left'); +text(30, 408, 'F11 / TICK GAUGE / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/tick-rows.md b/docs/assets/examples/zh/lieflat-charts/tick-rows.md new file mode 100644 index 0000000000..fe7e33f031 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/tick-rows.md @@ -0,0 +1,236 @@ +--- +category: examples +group: lieflat charts +title: F5 · 刻度条形图 +keywords: lieflat,F5,editorial,theme +order: 4 +cover: /vchart/preview/lieflat-charts-tick-rows.png +--- + +# F5 · 刻度条形图 + +每根竖线代表一次发布,每五根用圆点辅助计数。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 F5 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const teams = ['PLATFORM', 'GROWTH', 'MOBILE', 'INFRA', 'ML', 'DESIGN']; +[34, 28, 22, 17, 11, 8].forEach((value, i) => { + const y = 108 + i * 47; + text(125, y, teams[i], 11, muted, 'right', 600); + line(145, y + 11, 565, y + 11); + for (let k = 0; k < value; k++) { + const x = 150 + k * 11.5; + line(x, y + 8, x, y - 9, ink, 1, teams[i] + ': ' + value + ' releases'); + if (k % 5 === 4) dot(x, y + 16, 1, muted); + } + text(162 + value * 11.5, y, value, 15, ink, 'left', 800); +}); +text(30, 28, 'Tick rows', 19, ink, 'left', 700); +text(30, 52, 'Six teams, shipped and counted · one tick = one release', 10, muted, 'left'); +text(30, 408, 'F5 / TICK ROWS / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/trend-lineage.md b/docs/assets/examples/zh/lieflat-charts/trend-lineage.md new file mode 100644 index 0000000000..bf2d83c8fd --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/trend-lineage.md @@ -0,0 +1,253 @@ +--- +category: examples +group: lieflat charts +title: L11 · 功能生命周期图 +keywords: lieflat,L11,editorial,theme +order: 27 +cover: /vchart/preview/lieflat-charts-trend-lineage.png +--- + +# L11 · 功能生命周期图 + +逐行追踪功能首发、改版和休眠时间,不将事件汇总成计数。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 L11 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const features = [ + 'Dark mode', + 'Kanban', + 'Wikis', + 'Slash cmds', + 'Templates', + 'Inbox', + 'Live cursors', + 'AI drafts', + 'Voice notes', + 'Offline' +]; +features.forEach((name, row) => { + const start = 2016 + (row % 7), + end = row % 3 === 2 ? 2024 : 2026, + revised = Math.min(end, start + 3); + const y = 98 + row * 28, + x = year => 155 + (year - 2016) * 42; + text(137, y, name, 10, muted, 'right'); + line(x(start), y, x(end), y, row % 3 === 2 ? shades[1] : ink, 0.9); + dot(x(start), y, 3.5, ink, name + ': launched ' + start); + dot(x(revised), y, 4, paper, name + ': redesigned ' + revised, ink); + if (end < 2026) { + line(x(end) - 3, y - 3, x(end) + 3, y + 3, muted); + line(x(end) - 3, y + 3, x(end) + 3, y - 3, muted); + } else dot(x(end), y, 2, ink, name + ': active in 2026'); +}); +[2016, 2018, 2020, 2022, 2024, 2026].forEach(year => text(155 + (year - 2016) * 42, 385, year, 10)); + +text(30, 28, 'Trend lineage', 19, ink, 'left', 700); +text(30, 52, 'Features rise, fall, come back · solid = launch · hollow = redesign · × = dormant', 10, muted, 'left'); +text(30, 408, 'L11 / TREND LINEAGE / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/type-colonnade.md b/docs/assets/examples/zh/lieflat-charts/type-colonnade.md new file mode 100644 index 0000000000..33bf8cc1e5 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/type-colonnade.md @@ -0,0 +1,238 @@ +--- +category: examples +group: lieflat charts +title: L12 · 仓库归属柱廊 +keywords: lieflat,L12,editorial,theme +order: 28 +cover: /vchart/preview/lieflat-charts-type-colonnade.png +--- + +# L12 · 仓库归属柱廊 + +44 个仓库以独立名称保留,细线连接到 10 个负责人。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 L12 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启入场动画;自定义图形按数据顺序分批出现,文字先行显示。 +- 使用 `customMark` 和 `dataId` 绑定细线、点、文字与布局记录。 +- 同类图元共享数据集,整体等比缩放;悬停读取当前记录。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const layers = {}; +// 同类图元共用一个数据集和 mark,避免为每条记录创建独立组件。 +const put = (type, attrs, detail = '') => { + (layers[type] || (layers[type] = [])).push({ + ...(type === 'text' ? { angle: 0, textBaseline: 'middle', fontWeight: 400 } : {}), + ...attrs, + ...(type === 'text' + ? { fill: shades.includes(attrs.fill) ? ink : attrs.fill } + : { + ...(attrs.fill === ink ? { fill: shades[0] } : {}), + ...(attrs.stroke === ink ? { stroke: shades[0] } : {}) + }), + key: layers[type].length, + detail + }); +}; +const line = (x1, y1, x2, y2, stroke = grid, lineWidth = 1, detail = '') => + put('rule', { x: x1, y: y1, x1: x2, y1: y2, stroke, lineWidth }, detail); +const dot = (x, y, radius = 3, fill = ink, detail = '', stroke = fill) => + put('symbol', { x, y, size: radius * 2, symbolType: 'circle', fill, stroke, lineWidth: 1 }, detail); +const text = (x, y, value, fontSize = 10, fill = muted, textAlign = 'center', fontWeight = 400) => + put('text', { + x, + y, + text: String(value), + fontSize, + fill, + textAlign, + fontWeight, + fontFamily: font, + textBaseline: 'middle' + }); + +const teams = ['PLATFORM', 'FRONTEND', 'BACKEND', 'DATA', 'INFRA', 'MOBILE', 'SECURITY', 'GROWTH', 'ML', 'DESIGN']; +const counts = [7, 6, 6, 5, 5, 4, 3, 3, 3, 2]; +let repo = 0; +teams.forEach((name, group) => { + const x = 58 + group * 58; + text(x, 102, counts[group], 23, ink, 'center', 800); + put('text', { x, y: 134, text: name, fill: muted, fontFamily: font, fontSize: 8, textAlign: 'left', angle: 90 }); + for (let i = 0; i < counts[group]; i++) { + const y = 226 + i * 19; + line(x, 195, x, y, grid, 0.8); + dot(x, y, 2, ink, name + ' owns repo-' + ++repo); + text(x + 5, y, 'repo-' + repo, 7, muted, 'left'); + } +}); +text(30, 28, 'Type colonnade', 19, ink, 'left', 700); +text(30, 52, 'Forty-four repos, ten owners · every label is one repository', 10, muted, 'left'); +text(30, 408, 'L12 / TYPE COLONNADE / SIMULATED DATA', 9, muted, 'left'); +const data = Object.entries(layers).map(([type, values]) => ({ id: type, values })); +const children = ['rule', 'arc', 'rect', 'path', 'symbol', 'text'] + .filter(type => layers[type]) + .map(type => { + const values = layers[type]; + const keys = [...new Set(values.flatMap(Object.keys))].filter( + key => !['key', 'detail', 'nodeId', 'source', 'target', 'routeId', 'route', 'stage', 'node'].includes(key) + ); + return { + type, + id: 'lieflat-' + type, + dataId: type, + dataKey: 'key', + ...(type === 'text' ? { interactive: false } : {}), + style: { + ...Object.fromEntries( + keys.map(key => [ + key, + key === 'fill' || key === 'stroke' ? (datum, ctx) => themeColor(datum[key], ctx) : datum => datum[key] + ]) + ), + // 装饰图元不参与拾取;扩大细线的命中范围,不改变实际线宽。 + pickable: datum => Boolean(datum.detail), + pickStrokeBuffer: type === 'rule' ? 6 : type === 'path' || type === 'symbol' ? 4 : 0 + }, + animation: true, + // 文字先出现;图形在 450ms 内依次开始,整段入场约 1.1s。 + animationAppear: { + type: 'fadeIn', + duration: type === 'text' ? 450 : 650, + delay: type === 'text' ? 0 : datum => (datum.key / Math.max(1, values.length - 1)) * 450, + easing: 'cubicOut' + } + }; + }); +// 整组等比缩放,保持点阵、圆形和文字的比例;不依赖固定容器宽度。 +const scale = (_, ctx) => { + const { width, height } = ctx.vchart.getChart().getCanvasRect(); + return Math.min(width / 640, height / 440); +}; +const spec = { + animation: true, + type: 'common', + padding: 0, + data, + series: [], + customMark: [ + { + type: 'group', + style: { + x: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().width - 640 * scale(_, ctx)) / 2, + y: (_, ctx) => (ctx.vchart.getChart().getCanvasRect().height - 440 * scale(_, ctx)) / 2, + scaleX: scale, + scaleY: scale + }, + children + } + ], + // customMark 没有 series,使用下方 TooltipHandler 处理图元事件与提示生命周期。 + tooltip: { trigger: [] } +}; + +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +vchart.renderSync(); +// 通过标准 TooltipHandler 托管浮层,release 时自动移除。 +const container = vchart.getContainer(); +const tooltipElement = document.createElement('div'); +tooltipElement.setAttribute('role', 'tooltip'); +tooltipElement.className = 'lieflat-tooltip'; +tooltipElement.style.cssText = + 'position:absolute;visibility:hidden;pointer-events:none;z-index:10;box-sizing:border-box;' + + 'max-width:min(280px, calc(100% - 16px));padding:8px 11px;border:1px solid;' + + 'border-radius:4px;font:12px/1.5 ' + + font; +const updateTooltipTheme = () => { + const ctx = { vchart }; + tooltipElement.style.background = themeColor('popupBackgroundColor', ctx); + tooltipElement.style.color = themeColor(ink, ctx); + tooltipElement.style.borderColor = themeColor('borderColor', ctx); +}; +updateTooltipTheme(); +vchart.on('afterRender', updateTooltipTheme); +container.style.position = 'relative'; +container.appendChild(tooltipElement); +let tooltipWidth = 0; +let tooltipHeight = 0; +const tooltipHandler = { + showTooltip: (activeType, data, { event }) => { + const detail = data[0].datum[0].detail; + if (tooltipElement.textContent !== detail || tooltipElement.style.visibility === 'hidden') { + tooltipElement.textContent = detail; + tooltipWidth = tooltipElement.offsetWidth; + tooltipHeight = tooltipElement.offsetHeight; + } + const { width, height } = vchart.getChart().getCanvasRect(); + const x = Math.max(8, Math.min(event.canvasX + 12, width - tooltipWidth - 8)); + const y = Math.max(8, Math.min(event.canvasY + 12, height - tooltipHeight - 8)); + tooltipElement.style.left = x + 'px'; + tooltipElement.style.top = y + 'px'; + tooltipElement.style.visibility = 'visible'; + }, + hideTooltip: () => { + tooltipElement.style.visibility = 'hidden'; + }, + isTooltipShown: () => tooltipElement.style.visibility === 'visible', + release: () => tooltipElement.remove() +}; +vchart.setTooltipHandler(tooltipHandler); +// 使用 pointermove 的最终命中结果,避免相邻图元的 out/over 导致提示反复隐藏。 +vchart.on('pointermove', params => { + if (params.datum && params.datum.detail) { + tooltipHandler.showTooltip('mark', [{ datum: [params.datum] }], params); + } else { + tooltipHandler.hideTooltip(); + } +}); +vchart.on('pointerleave', { source: 'chart' }, tooltipHandler.hideTooltip); +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/us-choropleth.md b/docs/assets/examples/zh/lieflat-charts/us-choropleth.md new file mode 100644 index 0000000000..2fd2255758 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/us-choropleth.md @@ -0,0 +1,184 @@ +--- +category: examples +group: lieflat charts +title: M1 · 美国州级分级地图 +keywords: lieflat,M1,editorial,theme +order: 59 +cover: /vchart/preview/lieflat-charts-us-choropleth.png +--- + +# M1 · 美国州级分级地图 + +州界使用地理数据;连续色阶表示注册量,面积只表达地理范围,不表示人数。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 M1 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启原生入场动画;动态叙事示例的播放器自动播放。 +- 使用原生图表类型、坐标轴与数据绑定。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const axis = orient => ({ + orient, + domainLine: { visible: false }, + tick: { visible: false }, + grid: { visible: orient === 'left', style: { lineDash: [2, 4] } }, + label: { style: { fontSize: 10, fontFamily: font } } +}); +const base = { + padding: { top: 24, right: 30, bottom: 30, left: 30 }, + title: { + text: 'US choropleth', + subtext: 'Sign-ups across the states · geography is not quantity · neutral fill = no observation', + textStyle: { fontFamily: font, fontSize: 19 }, + subtextStyle: { fontFamily: font, fontSize: 11 }, + padding: { bottom: 28 } + }, + animationAppear: { duration: 900, easing: 'quartOut' }, + legends: { visible: false } +}; + +// 与现有地图示例使用同一数据源。复制到业务项目时可改为自托管 GeoJSON。 +const response = await fetch('https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/geojson/usa.json'); +if (!response.ok) throw new Error('Unable to load USA GeoJSON: ' + response.status); +const geojson = await response.json(); +VChart.registerMap('lieflat-usa', geojson); +const volumes = { + California: 96, + 'New York': 78, + Texas: 72, + Washington: 68, + Massachusetts: 66, + Florida: 54, + Illinois: 49, + Colorado: 44, + Georgia: 41, + Virginia: 33, + Pennsylvania: 31, + 'North Carolina': 30, + 'New Jersey': 28, + Oregon: 22, + Ohio: 21, + Michigan: 19, + Arizona: 18, + Minnesota: 16, + Utah: 15, + Maryland: 14, + Tennessee: 13, + Wisconsin: 12, + Missouri: 11, + Indiana: 9, + Nevada: 8, + Connecticut: 7, + 'South Carolina': 6, + Alabama: 5, + Kentucky: 5, + Oklahoma: 4, + Iowa: 4, + Kansas: 3, + Arkansas: 3, + Louisiana: 3, + 'New Hampshire': 2, + Idaho: 2, + 'New Mexico': 2, + Hawaii: 2, + Maine: 1, + Nebraska: 1, + Alaska: 1 +}; +const values = Object.entries(volumes).map(([name, value]) => ({ name, value })); +const spec = { + animation: true, + ...base, + type: 'map', + map: 'lieflat-usa', + nameField: 'name', + valueField: 'value', + nameProperty: 'name', + tooltip: { + mark: { + title: { value: d => d.name }, + content: [{ key: 'Accounts (k)', value: d => (d.value == null ? 'No observation' : d.value) }] + } + }, + data: [{ id: 'states', values }], + region: [{ projection: { type: 'albersUsa' } }], + color: { type: 'linear', domain: [0, 100], clamp: true }, + area: { + style: { + fill: (datum, ctx) => (datum.value == null ? themeColor(grid, ctx) : ctx.globalScale('color', datum.value)), + stroke: paint(paper), + lineWidth: 1 + }, + state: { hover: { stroke: paint(shades[0]), lineWidth: 2 } } + }, + legends: { + visible: true, + type: 'color', + orient: 'bottom', + position: 'middle', + field: 'value', + title: { visible: true, text: 'SIGN-UPS / THOUSANDS' } + } +}; +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +// 布局前更新标准颜色比例尺,区域和连续图例共享同一条主题渐变。 +vchart.on('layoutStart', () => { + const ctx = { vchart }; + vchart + .getScale('color') + .range([themeColor(sequential[5], ctx), themeColor(shades[0], ctx)]) + .domain([0, 100]); +}); +vchart.renderSync(); + +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/assets/examples/zh/lieflat-charts/world-choropleth.md b/docs/assets/examples/zh/lieflat-charts/world-choropleth.md new file mode 100644 index 0000000000..b66b5f2007 --- /dev/null +++ b/docs/assets/examples/zh/lieflat-charts/world-choropleth.md @@ -0,0 +1,178 @@ +--- +category: examples +group: lieflat charts +title: M2 · 世界分级地图 +keywords: lieflat,M2,editorial,theme +order: 60 +cover: /vchart/preview/lieflat-charts-world-choropleth.png +--- + +# M2 · 世界分级地图 + +国家轮廓与活跃账户数据按名称关联;缺失数据与零值在提示中区分。 + +参考 [Lieflat Charts](https://github.com/larashero3-dotcom/lieflat-charts) 的 M2 图型,使用 VChart 独立实现。演示数值为确定性模拟数据,不代表真实业务统计。 + +## 关键配置 + +- 默认开启原生入场动画;动态叙事示例的播放器自动播放。 +- 使用原生图表类型、坐标轴与数据绑定。 +- 背景、文字、数据色和 tooltip 跟随当前 VChart 主题;分类色与数值渐变分别处理。 + +## 代码演示 + +```javascript livedemo +// 文档站提供 VCHART_MODULE / VisUtil;项目中分别从 @visactor/vchart / @visactor/vutils 导入。 +const { getActualColor, getDataScheme, computeActualDataScheme } = VCHART_MODULE; +// 布局数据只保存颜色角色,实际颜色从当前 VChart 主题读取。 +const ink = 'primaryFontColor'; +const paper = 'backgroundColor'; +const muted = 'secondaryFontColor'; +const grid = 'axisGridColor'; +const shades = Array.from({ length: 6 }, (_, i) => 'category' + i); +const sequential = Array.from({ length: 6 }, (_, i) => 'intensity' + i); +let colorScheme; +let colors; +const themeColor = (role, ctx) => { + const nextScheme = ctx.vchart.getChart().getColorScheme(); + // 每次切换主题只解析一次,图元编码与 hover 复用结果。 + if (nextScheme !== colorScheme) { + colorScheme = nextScheme; + colors = Object.fromEntries( + [ink, paper, muted, grid, 'borderColor', 'popupBackgroundColor', 'disableFontColor'].map(key => [ + key, + getActualColor({ type: 'palette', key }, colorScheme) + ]) + ); + const dataColors = computeActualDataScheme(getDataScheme(colorScheme), shades); + shades.forEach((key, i) => { + colors[key] = dataColors[i % dataColors.length]; + }); + // 数值色阶保持单向强弱,不把分类色板误用为热力图渐变。 + const ramp = VisUtil.ColorUtil.interpolateRgb( + VisUtil.Color.parseColorString(colors.category0), + VisUtil.Color.parseColorString(colors[paper]) + ); + sequential.forEach((key, i) => { + colors[key] = ramp(i * 0.14).toString(); + }); + } + return colors[role] ?? role; +}; +const paint = role => (_, ctx) => themeColor(role, ctx); +const font = 'Inter, Arial, PingFang SC, sans-serif'; +// 确定性演示数据:刷新后保持一致,所有数值均为模拟数据。 +const sample = (i, seed = 1) => ((Math.sin(i * 127.1 + seed * 311.7) * 43758.5453) % 1) + 1; +const noise = (i, seed = 1) => sample(i, seed) % 1; +const axis = orient => ({ + orient, + domainLine: { visible: false }, + tick: { visible: false }, + grid: { visible: orient === 'left', style: { lineDash: [2, 4] } }, + label: { style: { fontSize: 10, fontFamily: font } } +}); +const base = { + padding: { top: 24, right: 30, bottom: 30, left: 30 }, + title: { + text: 'World choropleth', + subtext: 'Where the users are · monthly active accounts · drag to pan, wheel to zoom', + textStyle: { fontFamily: font, fontSize: 19 }, + subtextStyle: { fontFamily: font, fontSize: 11 }, + padding: { bottom: 28 } + }, + animationAppear: { duration: 900, easing: 'quartOut' }, + legends: { visible: false } +}; + +const response = await fetch('https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/geojson/world.json'); +if (!response.ok) throw new Error('Unable to load world GeoJSON: ' + response.status); +const geojson = await response.json(); +VChart.registerMap('lieflat-world', geojson); +const volumes = { + 'United States of America': 95, + India: 62, + 'United Kingdom': 48, + Germany: 41, + Brazil: 39, + Canada: 34, + France: 30, + Australia: 28, + China: 26, + Japan: 26, + 'South Korea': 21, + Spain: 19, + Italy: 17, + Singapore: 16, + Indonesia: 15, + Mexico: 14, + Netherlands: 13, + Poland: 13, + Sweden: 12, + Ireland: 12, + Philippines: 11, + Turkey: 10, + Vietnam: 9, + 'South Africa': 9, + Denmark: 9, + Norway: 8, + Thailand: 8, + Malaysia: 8, + Argentina: 7, + Nigeria: 7, + Chile: 6, + Colombia: 6, + 'New Zealand': 6, + Egypt: 5, + Pakistan: 5, + Russia: 5 +}; +const values = Object.entries(volumes).map(([name, value]) => ({ name, value })); +const spec = { + animation: true, + ...base, + type: 'map', + map: 'lieflat-world', + nameField: 'name', + valueField: 'value', + nameProperty: 'name', + tooltip: { + mark: { + title: { value: d => d.name }, + content: [{ key: 'Accounts (k)', value: d => (d.value == null ? 'No observation' : d.value) }] + } + }, + data: [{ id: 'countries', values }], + region: [{ roam: true, projection: { type: 'equirectangular' } }], + color: { type: 'linear', domain: [0, 100], clamp: true }, + area: { + style: { + fill: (datum, ctx) => (datum.value == null ? themeColor(grid, ctx) : ctx.globalScale('color', datum.value)), + stroke: paint(paper), + lineWidth: 0.6 + }, + state: { hover: { stroke: paint(shades[0]), lineWidth: 1.5 } } + }, + legends: { + visible: true, + type: 'color', + orient: 'bottom', + position: 'middle', + field: 'value', + title: { visible: true, text: 'MONTHLY ACTIVE ACCOUNTS / THOUSANDS' } + } +}; +const vchart = new VChart(spec, { dom: CONTAINER_ID }); +// 布局前更新标准颜色比例尺,区域和连续图例共享同一条主题渐变。 +vchart.on('layoutStart', () => { + const ctx = { vchart }; + vchart + .getScale('color') + .range([themeColor(sequential[5], ctx), themeColor(shades[0], ctx)]) + .domain([0, 100]); +}); +vchart.renderSync(); + +// 通过 vchart.setCurrentTheme('已注册的主题名') 切换,无需重新计算业务数据。 +// 仅用于控制台调试。 +window['vchart'] = vchart; +``` diff --git a/docs/public/vchart/preview/lieflat-charts-aggregate-sankey.png b/docs/public/vchart/preview/lieflat-charts-aggregate-sankey.png new file mode 100644 index 0000000000..86dd8229ab Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-aggregate-sankey.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-arc-matrix.png b/docs/public/vchart/preview/lieflat-charts-arc-matrix.png new file mode 100644 index 0000000000..3c4872298c Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-arc-matrix.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-ballot-tally.png b/docs/public/vchart/preview/lieflat-charts-ballot-tally.png new file mode 100644 index 0000000000..5a400180f2 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-ballot-tally.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-bar-race.png b/docs/public/vchart/preview/lieflat-charts-bar-race.png new file mode 100644 index 0000000000..e5a08ff59b Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-bar-race.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-barcode-lollipop.png b/docs/public/vchart/preview/lieflat-charts-barcode-lollipop.png new file mode 100644 index 0000000000..40ec3544df Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-barcode-lollipop.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-brand-spectrum.png b/docs/public/vchart/preview/lieflat-charts-brand-spectrum.png new file mode 100644 index 0000000000..4cf4cc0bf7 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-brand-spectrum.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-bubble-almanac.png b/docs/public/vchart/preview/lieflat-charts-bubble-almanac.png new file mode 100644 index 0000000000..dbd47cec28 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-bubble-almanac.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-calendar-heat.png b/docs/public/vchart/preview/lieflat-charts-calendar-heat.png new file mode 100644 index 0000000000..716fa7fe81 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-calendar-heat.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-chunky-bars.png b/docs/public/vchart/preview/lieflat-charts-chunky-bars.png new file mode 100644 index 0000000000..6d6f828bbc Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-chunky-bars.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-circular-network.png b/docs/public/vchart/preview/lieflat-charts-circular-network.png new file mode 100644 index 0000000000..1357693450 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-circular-network.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-cluster-field.png b/docs/public/vchart/preview/lieflat-charts-cluster-field.png new file mode 100644 index 0000000000..c3bd54ad59 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-cluster-field.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-diverging-bars.png b/docs/public/vchart/preview/lieflat-charts-diverging-bars.png new file mode 100644 index 0000000000..2fd179f599 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-diverging-bars.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-dot-cascade.png b/docs/public/vchart/preview/lieflat-charts-dot-cascade.png new file mode 100644 index 0000000000..3e52c0fc45 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-dot-cascade.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-dot-heat.png b/docs/public/vchart/preview/lieflat-charts-dot-heat.png new file mode 100644 index 0000000000..e563ffc7ed Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-dot-heat.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-dot-waffle.png b/docs/public/vchart/preview/lieflat-charts-dot-waffle.png new file mode 100644 index 0000000000..0ce05a67e0 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-dot-waffle.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-dotty-matrix.png b/docs/public/vchart/preview/lieflat-charts-dotty-matrix.png new file mode 100644 index 0000000000..4f357e955f Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-dotty-matrix.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-draw-in-counter.png b/docs/public/vchart/preview/lieflat-charts-draw-in-counter.png new file mode 100644 index 0000000000..cbb48827e4 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-draw-in-counter.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-dumbbell-queue.png b/docs/public/vchart/preview/lieflat-charts-dumbbell-queue.png new file mode 100644 index 0000000000..9eab48e0d6 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-dumbbell-queue.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-hairline-area.png b/docs/public/vchart/preview/lieflat-charts-hairline-area.png new file mode 100644 index 0000000000..e057f6010d Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-hairline-area.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-hairline-line.png b/docs/public/vchart/preview/lieflat-charts-hairline-line.png new file mode 100644 index 0000000000..945a472e83 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-hairline-line.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-hourglass-stream.png b/docs/public/vchart/preview/lieflat-charts-hourglass-stream.png new file mode 100644 index 0000000000..120c3f0405 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-hourglass-stream.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-hundred-field.png b/docs/public/vchart/preview/lieflat-charts-hundred-field.png new file mode 100644 index 0000000000..b3bc49ab90 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-hundred-field.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-launch-fan.png b/docs/public/vchart/preview/lieflat-charts-launch-fan.png new file mode 100644 index 0000000000..7b048f0d0d Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-launch-fan.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-nested-treemap.png b/docs/public/vchart/preview/lieflat-charts-nested-treemap.png new file mode 100644 index 0000000000..41f5cb8ffe Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-nested-treemap.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-paired-rungs.png b/docs/public/vchart/preview/lieflat-charts-paired-rungs.png new file mode 100644 index 0000000000..470ca4bc72 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-paired-rungs.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-pictorial-forest.png b/docs/public/vchart/preview/lieflat-charts-pictorial-forest.png new file mode 100644 index 0000000000..221fbbff96 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-pictorial-forest.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-platform-tree.png b/docs/public/vchart/preview/lieflat-charts-platform-tree.png new file mode 100644 index 0000000000..4bdd0412c4 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-platform-tree.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-radial-convergence.png b/docs/public/vchart/preview/lieflat-charts-radial-convergence.png new file mode 100644 index 0000000000..2bfe705e49 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-radial-convergence.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-radial-patchwork.png b/docs/public/vchart/preview/lieflat-charts-radial-patchwork.png new file mode 100644 index 0000000000..3705708ed0 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-radial-patchwork.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-rank-strip.png b/docs/public/vchart/preview/lieflat-charts-rank-strip.png new file mode 100644 index 0000000000..236c90eb4a Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-rank-strip.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-ridgeline.png b/docs/public/vchart/preview/lieflat-charts-ridgeline.png new file mode 100644 index 0000000000..934ee65412 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-ridgeline.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-rung-bars.png b/docs/public/vchart/preview/lieflat-charts-rung-bars.png new file mode 100644 index 0000000000..d8b065ff45 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-rung-bars.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-rung-histogram.png b/docs/public/vchart/preview/lieflat-charts-rung-histogram.png new file mode 100644 index 0000000000..ce91a8de1f Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-rung-histogram.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-rung-waterfall.png b/docs/public/vchart/preview/lieflat-charts-rung-waterfall.png new file mode 100644 index 0000000000..949696ec28 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-rung-waterfall.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-stacked-rungs.png b/docs/public/vchart/preview/lieflat-charts-stacked-rungs.png new file mode 100644 index 0000000000..bb4645ded7 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-stacked-rungs.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-stream-ribbon.png b/docs/public/vchart/preview/lieflat-charts-stream-ribbon.png new file mode 100644 index 0000000000..bf65ea208c Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-stream-ribbon.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-tick-donut.png b/docs/public/vchart/preview/lieflat-charts-tick-donut.png new file mode 100644 index 0000000000..79fa8b5b7c Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-tick-donut.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-tick-gauge.png b/docs/public/vchart/preview/lieflat-charts-tick-gauge.png new file mode 100644 index 0000000000..62e269537b Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-tick-gauge.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-tick-rows.png b/docs/public/vchart/preview/lieflat-charts-tick-rows.png new file mode 100644 index 0000000000..476a0ab701 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-tick-rows.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-trend-lineage.png b/docs/public/vchart/preview/lieflat-charts-trend-lineage.png new file mode 100644 index 0000000000..a0cf8065fd Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-trend-lineage.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-type-colonnade.png b/docs/public/vchart/preview/lieflat-charts-type-colonnade.png new file mode 100644 index 0000000000..2de6fe95e8 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-type-colonnade.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-us-choropleth.png b/docs/public/vchart/preview/lieflat-charts-us-choropleth.png new file mode 100644 index 0000000000..8f56993e1b Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-us-choropleth.png differ diff --git a/docs/public/vchart/preview/lieflat-charts-world-choropleth.png b/docs/public/vchart/preview/lieflat-charts-world-choropleth.png new file mode 100644 index 0000000000..55a07e07d9 Binary files /dev/null and b/docs/public/vchart/preview/lieflat-charts-world-choropleth.png differ diff --git a/docs/src/markdown.tsx b/docs/src/markdown.tsx index 8db5571f94..77e23c9cd6 100644 --- a/docs/src/markdown.tsx +++ b/docs/src/markdown.tsx @@ -230,7 +230,7 @@ export function Markdown() { // remove meta info for examples if (assetDirectory.includes('examples')) { const coverLink = processedText.match(/cover:(.*)/)?.[1]; - processedText = processedText.replace(/---(.|\n)*---/, '').trim(); + processedText = processedText.replace(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/, '').trim(); if (coverLink) { processedText = processedText + `\n## Cover:\n![cover](${coverLink.trim()})`; } diff --git a/packages/vchart-extension/src/charts/3d/util.ts b/packages/vchart-extension/src/charts/3d/util.ts index c82f290295..04b111e249 100644 --- a/packages/vchart-extension/src/charts/3d/util.ts +++ b/packages/vchart-extension/src/charts/3d/util.ts @@ -28,5 +28,5 @@ export const is3DChart = (spec: any) => { return true; } - return true; + return !!spec.series?.some((series: any) => is3DChartType(series.type)); }; diff --git a/packages/vchart/__tests__/unit/extension/3d-chart-detection.test.ts b/packages/vchart/__tests__/unit/extension/3d-chart-detection.test.ts new file mode 100644 index 0000000000..fec5e5737a --- /dev/null +++ b/packages/vchart/__tests__/unit/extension/3d-chart-detection.test.ts @@ -0,0 +1,28 @@ +import { is3DChart } from '../../../../vchart-extension/src/charts/3d/util'; + +describe('3D chart detection', () => { + test.each([ + { type: 'bar', xField: 'category', yField: 'value' }, + { type: 'line', xField: 'category', yField: 'value' }, + { type: 'common', series: [], customMark: [{ type: 'group', style: { scaleX: 0.9, scaleY: 0.9 } }] }, + { type: 'common', series: [{ type: 'bar' }, { type: 'line' }] } + ])('keeps 2D charts out of the camera rendering path: %o', spec => { + expect(is3DChart(spec)).toBe(false); + }); + + test.each(['bar3d', 'histogram3d', 'rangeColumn3d', 'pie3d', 'wordCloud3d', 'funnel3d'])( + 'recognizes %s charts', + type => { + expect(is3DChart({ type })).toBe(true); + } + ); + + test('recognizes 3D series in a combination chart', () => { + expect(is3DChart({ type: 'common', series: [{ type: 'line' }, { type: 'bar3d' }] })).toBe(true); + }); + + test('recognizes a 3D axis declared by zField', () => { + expect(is3DChart({ type: 'scatter', xField: 'x', yField: 'y', zField: 'z' })).toBe(true); + expect(is3DChart({ type: 'common', series: [{ type: 'line', zField: 'z' }] })).toBe(true); + }); +});