工作台视频列表页新增点赞/评论趋势折线图(lieflat-charts G8 骨架)
- 引入 lieflat-charts 第三方图表技能到 subskills/(SKILL.md+catalog+templates,已删嵌套 .git) - ECharts 6.1.0 本地化至 mcn-work-shop/public/vendor/(零 CDN 依赖,离线可用) - 账号详情视频列表 tab 上方新增趋势图:X=发布时间升序,点赞/评论双序列面积折线 (G8 Rainfall 骨架:双 grid 独立量级、同 X 联动 pointer、tipLight tooltip、quarticOut 入场) - 全量视频数据单独拉取(pageSize=500,不随分页变化);numOf 解析 'x.x万' 文本 - V1.0/SKILL.md 登记 lieflat-charts 外部技能依赖
No files matched your search
@@ -108,6 +108,16 @@ agent_created: true
|
||||
- **进程生命周期(红线,09-02 用户明确)**:已开启的 Chrome(9223 调试实例/用户手动开启的 Chrome)一律不主动关闭,除非用户明确要求;任务结束/失败都保留,承载登录态与标签页复用
|
||||
- 调用前必做环境检查(三要素:工具/daemon/Chrome),详见 mcn-dou-analysis `references/浏览器搜索抖音账号操作规范.md`
|
||||
|
||||
## 外部技能依赖:图表生成(lieflat-charts,09-02 引入)
|
||||
|
||||
工作台/数据页需要生成图表时,使用 **lieflat-charts**(第三方 Agent 图表技能,PolyForm Noncommercial 许可):
|
||||
|
||||
- **技能本体**:`project/短视频脚本创作/V1.0/subskills/lieflat-charts/`(含 SKILL.md + catalog.md + templates/ 各 gallery,随源仓库分发)
|
||||
- **选型规则**:先读 `catalog.md` 按数据形状锁定图型编号,再打开 `templates/` 对应 gallery 的真实实现复制骨架;默认顺序 Lupi Editorial → Lupi Basics → Glance(工作台场景数据量小/需快读时可直用 Glance)
|
||||
- **视觉 token**:`mono-tokens.js`(纸 #F0EFEB / 墨 #1C1C1A / 灰 #8F8E88 / 网格 #DEDDD6),同一交付只锁一种色系
|
||||
- **ECharts 依赖**:已本地化至工作台 `mcn-work-shop/public/vendor/echarts.min.js`(零 CDN 依赖,离线可用);引用样例见工作台账号详情页视频列表上方「点赞/评论趋势折线图」(G8 Rainfall 骨架:双 grid、双序列随时间、tipLight tooltip、quarticOut 入场)
|
||||
- **使用限制**:库外新造是最后手段(须继承最接近 gallery 模板的视觉语法);不得拼接多个模板造混合图型
|
||||
|
||||
---
|
||||
|
||||
## 11 步创作流程
|
||||
|
||||
@@ -16,6 +16,14 @@ const fmtWan = (v) => {
|
||||
if (!Number.isFinite(n) || n <= 0) return '-';
|
||||
return n >= 10000 ? (n / 10000).toFixed(1) + '万' : String(n);
|
||||
};
|
||||
/* 数值解析(趋势图用):"7.8万" 文本 → 78000;数字/空字符串 → 数值或 0(与 fmtWan 相反方向的转换) */
|
||||
const numOf = (v) => {
|
||||
if (v == null || v === '') return 0;
|
||||
const s = String(v).trim();
|
||||
if (s.includes('万')) { const n = parseFloat(s); return Number.isFinite(n) ? Math.round(n * 10000) : 0; }
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
/* AI创作用账号人设:剔除「## 十一、当前需解决的问题」及之后的非创作章节(十二、设定分析说明等),只留创作素材(09-01 用户要求) */
|
||||
const personaForCreate = (text) => {
|
||||
const s = String(text || '').trim();
|
||||
@@ -373,6 +381,7 @@ async function renderAccountDetail(view, id) {
|
||||
if (!d.ok) { view.innerHTML = `<div class="page"><div class="page-toolbar">${toolbarBack()}</div><div class="empty">${esc(d.error || '账号不存在')}</div></div>`; bindBack(view); return; }
|
||||
const acc = d.account;
|
||||
let persona = null, analysis = null, personaCard = null;
|
||||
let trendData = null; // 全量视频趋势数据(折线图用,不随分页变化)
|
||||
let viewMode = 'card'; // 账号设定展示模式:card(卡片版,默认)/ text(文字版)
|
||||
|
||||
async function loadPersona() {
|
||||
@@ -387,10 +396,16 @@ async function renderAccountDetail(view, id) {
|
||||
const r = await api('/api/dsh/account-analysis?accountId=' + accountId);
|
||||
analysis = r.content || null;
|
||||
}
|
||||
// 全量视频(一次拉取,供趋势折线图;不随表格分页)
|
||||
async function loadTrend() {
|
||||
const r = await api('/api/dsh/account-videos?accountId=' + accountId + '&page=1&pageSize=500').catch(() => null);
|
||||
trendData = (r && r.items) || [];
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const dvP = api('/api/dsh/account-videos?accountId=' + accountId + '&page=' + page + '&pageSize=20');
|
||||
if (tab === 'info' && persona === null) await loadPersona(); // 账号设定内联在信息页,首屏即加载(与视频数据并行)
|
||||
if (tab === 'persona' && trendData === null) await loadTrend(); // 视频列表页首屏拉全量趋势数据(与分页数据并行)
|
||||
const dv = await dvP;
|
||||
const infoGrid = (label, val, wide, raw) => `<div class="info-item ${wide ? 'info-wide' : ''}"><div class="info-label">${label}</div><div class="info-value">${raw ? val : esc(fmt(val))}</div></div>`;
|
||||
const rows = (dv.items || []).map((v) => `<tr>
|
||||
@@ -445,7 +460,8 @@ async function renderAccountDetail(view, id) {
|
||||
<h3 class="video-list-title">账号设定</h3>
|
||||
${personaPanel()}`;
|
||||
} else if (tab === 'persona') {
|
||||
panel = `<div class="table-wrap"><table class="table"><thead><tr>${bstate.batch ? '<th class="cb-cell"><input type="checkbox" class="batch-cb-all"></th>' : ''}<th class="th-pub">发布时间</th><th class="th-title">视频标题</th><th class="num">点赞</th><th class="num">评论</th><th class="num">分享</th><th class="num">收藏</th><th class="num">时长</th><th class="th-act">操作</th></tr></thead><tbody>${rows || `<tr><td colspan="${bstate.batch ? 9 : 8}" class="empty">暂无视频数据</td></tr>`}</tbody></table></div>
|
||||
panel = `<div class="trend-chart" id="trendChart"></div>
|
||||
<div class="table-wrap"><table class="table"><thead><tr>${bstate.batch ? '<th class="cb-cell"><input type="checkbox" class="batch-cb-all"></th>' : ''}<th class="th-pub">发布时间</th><th class="th-title">视频标题</th><th class="num">点赞</th><th class="num">评论</th><th class="num">分享</th><th class="num">收藏</th><th class="num">时长</th><th class="th-act">操作</th></tr></thead><tbody>${rows || `<tr><td colspan="${bstate.batch ? 9 : 8}" class="empty">暂无视频数据</td></tr>`}</tbody></table></div>
|
||||
<div class="list-footer">
|
||||
${batchBarHtml(bstate, 0)}
|
||||
${pagerHtml(dv.total, dv.page, 20, (p) => { page = p; load(); })}
|
||||
@@ -460,6 +476,7 @@ async function renderAccountDetail(view, id) {
|
||||
<div class="detail-body">${panel}</div>
|
||||
</div>`;
|
||||
bindBack(view);
|
||||
if (tab === 'persona') renderTrendChart(view); // 视频列表页上方:点赞/评论趋势折线图(lieflat-charts G8 骨架,ECharts)
|
||||
view.querySelectorAll('[data-tab]').forEach((b) => b.addEventListener('click', async () => {
|
||||
tab = b.dataset.tab;
|
||||
if (tab === 'analysis' && analysis === null) await loadAnalysis();
|
||||
@@ -526,6 +543,52 @@ async function renderAccountDetail(view, id) {
|
||||
}));
|
||||
view._reload = load; // 静默自动刷新(保留当前 tab/翻页)
|
||||
}
|
||||
|
||||
/* 点赞/评论趋势折线图:X=视频发布时间(升序),点赞/评论双序列面积折线(lieflat-charts G8 Rainfall 骨架)
|
||||
mono token:纸 #F0EFEB / 墨 #1C1C1A / 灰 #8F8E88 / 网格 #DEDDD6;tipLight 黑底纸字;quarticOut 入场 */
|
||||
function renderTrendChart(view) {
|
||||
const el = view.querySelector('#trendChart');
|
||||
if (!el || typeof echarts === 'undefined') return;
|
||||
const items = (trendData || []).filter((v) => v.publish_time);
|
||||
if (items.length < 2) { el.innerHTML = `<div class="trend-empty">暂无足够视频数据展示趋势</div>`; return; }
|
||||
const pts = items
|
||||
.map((v) => ({ t: String(v.publish_time).replace(/-/g, '/'), like: numOf(v.like_display || v.like_count), cmt: numOf(v.comment_count) }))
|
||||
.filter((p) => p.like > 0 || p.cmt > 0)
|
||||
.sort((a, b) => new Date(a.t) - new Date(b.t));
|
||||
if (pts.length < 2) { el.innerHTML = `<div class="trend-empty">暂无足够视频数据展示趋势</div>`; return; }
|
||||
const INK = '#1C1C1A', PAPER = '#F0EFEB', MUTED = '#8F8E88', GRID = '#DEDDD6', FAINT = '#C6C5BF';
|
||||
const tipLight = { backgroundColor: INK, borderWidth: 0, padding: [10, 14], textStyle: { color: PAPER, fontFamily: 'Inter, system-ui, sans-serif', fontSize: 12 } };
|
||||
const chart = echarts.getInstanceByDom(el) || echarts.init(el);
|
||||
chart.clear();
|
||||
chart.setOption({
|
||||
animationDuration: 1200, animationEasing: 'quarticOut',
|
||||
tooltip: { ...tipLight, trigger: 'axis', axisPointer: { type: 'cross' },
|
||||
formatter: (ps) => {
|
||||
const p = ps && ps[0]; if (!p) return '';
|
||||
const d = pts[p.dataIndex];
|
||||
return `${d.t.split(' ')[0]}<br/>点赞 ${fmtWan(d.like)} · 评论 ${fmtWan(d.cmt)}`;
|
||||
} },
|
||||
axisPointer: { link: [{ xAxisIndex: 'all' }], lineStyle: { color: '#B0AFA9' } },
|
||||
grid: [
|
||||
{ left: 52, right: 16, top: 10, height: '26%' },
|
||||
{ left: 52, right: 16, top: '42%', bottom: 26 },
|
||||
],
|
||||
xAxis: [
|
||||
{ gridIndex: 0, type: 'category', data: pts.map((p) => p.t), axisLine: { show: false }, axisTick: { show: false }, axisLabel: { show: false } },
|
||||
{ gridIndex: 1, type: 'category', data: pts.map((p) => p.t), axisLine: { show: false }, axisTick: { show: false },
|
||||
axisLabel: { color: MUTED, fontFamily: 'Inter, system-ui, sans-serif', fontSize: 9.5, interval: Math.max(0, Math.ceil(pts.length / 8) - 1), formatter: (v) => v.slice(5, 10) } },
|
||||
],
|
||||
yAxis: [
|
||||
{ gridIndex: 0, splitLine: { show: false }, axisLabel: { color: FAINT, fontFamily: 'Inter, system-ui, sans-serif', fontSize: 9 }, axisLine: { show: false }, axisTick: { show: false } },
|
||||
{ gridIndex: 1, splitLine: { lineStyle: { color: GRID } }, axisLabel: { color: MUTED, fontFamily: 'Inter, system-ui, sans-serif', fontSize: 9.5 }, axisLine: { show: false }, axisTick: { show: false } },
|
||||
],
|
||||
series: [
|
||||
{ name: '评论', type: 'line', xAxisIndex: 0, yAxisIndex: 0, data: pts.map((p) => p.cmt), smooth: 0.4, symbol: 'none', lineStyle: { color: MUTED, width: 1.8 }, z: 2 },
|
||||
{ name: '点赞', type: 'line', xAxisIndex: 1, yAxisIndex: 1, data: pts.map((p) => p.like), smooth: 0.4, symbol: 'none', lineStyle: { color: INK, width: 2.2 }, z: 3,
|
||||
areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: 'rgba(28,28,26,.22)' }, { offset: 1, color: 'rgba(28,28,26,0)' }] } } },
|
||||
],
|
||||
});
|
||||
}
|
||||
await load();
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MCN工作台</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script src="vendor/echarts.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
|
||||
@@ -365,6 +365,11 @@ td.td-title { max-width: 240px; }
|
||||
.card-view img { width: 100%; height: auto; display: block; border-radius: 8px; }
|
||||
.vs-hidden { display: none !important; }
|
||||
|
||||
/* ================= 点赞/评论趋势折线图(视频列表上方,lieflat-charts G8 骨架) ================= */
|
||||
.trend-chart { border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px 4px; margin-bottom: 12px; background: var(--panel); }
|
||||
.trend-chart #trendChart, .trend-chart .trend-empty { height: 240px; width: 100%; }
|
||||
.trend-empty { display: flex; align-items: center; justify-content: center; color: var(--dim); font-size: 13px; }
|
||||
|
||||
/* ================= 信息卡 ================= */
|
||||
.info-card { border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; margin-bottom: 14px; background: var(--panel); }
|
||||
.info-card-title { font-size: 15px; font-weight: 600; color: var(--text); margin-bottom: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).echarts={})}(this,function(t){"use strict";
|
||||
/*! *****************************************************************************
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
PERFORMANCE OF THIS SOFTWARE.
|
||||
***************************************************************************** */var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n])},e(t,n)};function n(t,n){if("function"!=typeof n&&null!==n)throw new TypeError("Class extends value "+String(n)+" is not a constructor or null");function i(){this.constructor=t}e(t,n),t.prototype=null===n?Object.create(n):(i.prototype=n.prototype,new i)}var i=function(){this.firefox=!1,this.ie=!1,this.edge=!1,this.newEdge=!1,this.weChat=!1},r=new function(){this.browser=new i,this.node=!1,this.wxa=!1,this.worker=!1,this.svgSupported=!1,this.touchEventsSupported=!1,this.pointerEventsSupported=!1,this.domSupported=!1,this.transformSupported=!1,this.transform3dSupported=!1,this.hasGlobalWindow="undefined"!=typeof window};"object"==typeof wx&&"function"==typeof wx.getSystemInfoSync?(r.wxa=!0,r.touchEventsSupported=!0):"undefined"==typeof document&&"undefined"!=typeof self?r.worker=!0:!r.hasGlobalWindow||"Deno"in window||"undefined"!=typeof navigator&&"string"==typeof navigator.userAgent&&navigator.userAgent.indexOf("Node.js")>-1?(r.node=!0,r.svgSupported=!0):function(t,e){var n=e.browser,i=t.match(/Firefox\/([\d.]+)/),r=t.match(/MSIE\s([\d.]+)/)||t.match(/Trident\/.+?rv:(([\d.]+))/),o=t.match(/Edge?\/([\d.]+)/),a=/micromessenger/i.test(t);i&&(n.firefox=!0,n.version=i[1]);r&&(n.ie=!0,n.version=r[1]);o&&(n.edge=!0,n.version=o[1],n.newEdge=+o[1].split(".")[0]>18);a&&(n.weChat=!0);e.svgSupported="undefined"!=typeof SVGRect,e.touchEventsSupported="ontouchstart"in window&&!n.ie&&!n.edge,e.pointerEventsSupported="onpointerdown"in window&&(n.edge||n.ie&&+n.version>=11);var s=e.domSupported="undefined"!=typeof document;if(s){var l=document.documentElement.style;e.transform3dSupported=(n.ie&&"transition"in l||n.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in l)&&!("OTransition"in l),e.transformSupported=e.transform3dSupported||n.ie&&+n.version>=9}}(navigator.userAgent,r);var o="sans-serif",a="12px "+o;var s,l,u=function(t){var e={};if("undefined"==typeof JSON)return e;for(var n=0;n<t.length;n++){var i=String.fromCharCode(n+32),r=(t.charCodeAt(n)-20)/100;e[i]=r}return e}("007LLmW'55;N0500LLLLLLLLLL00NNNLzWW\\\\WQb\\0FWLg\\bWb\\WQ\\WrWWQ000CL5LLFLL0LL**F*gLLLL5F0LF\\FFF5.5N"),c={createCanvas:function(){return"undefined"!=typeof document&&document.createElement("canvas")},measureText:function(t,e){if(!s){var n=c.createCanvas();s=n&&n.getContext("2d")}if(s)return l!==e&&(l=s.font=e||a),s.measureText(t);t=t||"";var i=/((?:\d+)?\.?\d*)px/.exec(e=e||a),r=i&&+i[1]||12,o=0;if(e.indexOf("mono")>=0)o=r*t.length;else for(var h=0;h<t.length;h++){var d=u[t[h]];o+=null==d?r:d*r}return{width:o}},loadImage:function(t,e,n){var i=new Image;return i.onload=e,i.onerror=n,i.src=t,i},getTime:function(){return Date.now?Date.now():+new Date}};function h(t){for(var e in c)c.hasOwnProperty(e)&&t[e]&&(c[e]=t[e])}var d=G(["Function","RegExp","Date","Error","CanvasGradient","CanvasPattern","Image","Canvas"],function(t,e){return t["[object "+e+"]"]=!0,t},{}),p=G(["Int8","Uint8","Uint8Clamped","Int16","Uint16","Int32","Uint32","Float32","Float64"],function(t,e){return t["[object "+e+"Array]"]=!0,t},{}),f=Object.prototype.toString,g=Array.prototype,v=g.forEach,y=g.filter,m=g.slice,x=g.map,_=function(){}.constructor,b=_?_.prototype:null,w="__proto__",S=2311,M=Math.pow(2,53)-1;function I(){return S>=M&&(S=0),S++}function T(){for(var t=[],e=0;e<arguments.length;e++)t[e]=arguments[e];"undefined"!=typeof console&&console.error.apply(console,t)}function C(t){if(null==t||"object"!=typeof t)return t;var e=t,n=f.call(t);if("[object Array]"===n){if(!ft(t)){e=[];for(var i=0,r=t.length;i<r;i++)e[i]=C(t[i])}}else if(p[n]){if(!ft(t)){var o=t.constructor;if(o.from)e=o.from(t);else{e=new o(t.length);for(i=0,r=t.length;i<r;i++)e[i]=t[i]}}}else if(!d[n]&&!ft(t)&&!tt(t))for(var a in e={},t)t.hasOwnProperty(a)&&a!==w&&(e[a]=C(t[a]));return e}function D(t,e,n){if(!$(e)||!$(t))return n?C(e):t;for(var i in e)if(e.hasOwnProperty(i)&&i!==w){var r=t[i],o=e[i];!$(o)||!$(r)||X(o)||X(r)||tt(o)||tt(r)||J(o)||J(r)||ft(o)||ft(r)?!n&&i in t||(t[i]=C(e[i])):D(r,o,n)}return t}function A(t,e){for(var n=t[0],i=1,r=t.length;i<r;i++)n=D(n,t[i],e);return n}function k(t,e){if(Object.assign)Object.assign(t,e);else for(var n in e)e.hasOwnProperty(n)&&n!==w&&(t[n]=e[n]);return t}function L(t,e,n){t=t||{};for(var i=0;i<n.length;i++){var r=n[i];t[r]=e[r]}return t}function P(t,e,n){for(var i=W(e),r=0,o=i.length;r<o;r++){var a=i[r];(n?null!=e[a]:null==t[a])&&(t[a]=e[a])}return t}var O=c.createCanvas;function R(t,e){if(t){if(t.indexOf)return t.indexOf(e);for(var n=0,i=t.length;n<i;n++)if(t[n]===e)return n}return-1}function N(t,e){var n=t.prototype;function i(){}for(var r in i.prototype=e.prototype,t.prototype=new i,n)n.hasOwnProperty(r)&&(t.prototype[r]=n[r]);t.prototype.constructor=t,t.supLine truncated
|
||||
/*!
|
||||
* ZRender, a high performance 2d drawing library.
|
||||
*
|
||||
* Copyright (c) 2013, Baidu Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* LICENSE
|
||||
* https://github.com/ecomfe/zrender/blob/master/LICENSE
|
||||
*/
|
||||
var yo={},mo={};var xo,_o=function(){function t(t,e,n){var i=this;this._sleepAfterStill=10,this._stillFrameAccum=0,this._needsRefresh=!0,this._needsRefreshHover=!1,this._darkMode=!1,n=n||{},this.dom=e,this.id=t;var o=new Mn,a=n.renderer||"canvas";yo[a]||(a=W(yo)[0]),n.useDirtyRect=null!=n.useDirtyRect&&n.useDirtyRect;var s=new yo[a](e,o,n,t),l=n.ssr||s.ssrOnly;this.storage=o,this.painter=s;var u,c=r.node||r.worker||l?null:new Ir(s.getViewportRoot(),s.root),h=n.useCoarsePointer;(null==h||"auto"===h?r.touchEventsSupported:!!h)&&(u=at(n.pointerSize,44)),this.handler=new hn(o,s,c,s.root,u),this.animation=new lr({stage:{update:l?null:function(){return i._flush(!1)}}}),l||this.animation.start()}return t.prototype.add=function(t){!this._disposed&&t&&(this.storage.addRoot(t),t.addSelfToZr(this),this.refresh())},t.prototype.remove=function(t){!this._disposed&&t&&(this.storage.delRoot(t),t.removeSelfFromZr(this),this.refresh())},t.prototype.configLayer=function(t,e){this._disposed||(this.painter.configLayer&&this.painter.configLayer(t,e),this.refresh())},t.prototype.setBackgroundColor=function(t){this._disposed||(this.painter.setBackgroundColor&&this.painter.setBackgroundColor(t),this.refresh(),this._backgroundColor=t,this._darkMode=function(t){if(!t)return!1;if("string"==typeof t)return Ii(t,1)<.4;if(t.colorStops){for(var e=t.colorStops,n=0,i=e.length,r=0;r<i;r++)n+=Ii(e[r].color,1);return(n/=i)<.4}return!1}(t))},t.prototype.getBackgroundColor=function(){return this._backgroundColor},t.prototype.setDarkMode=function(t){this._darkMode=t},t.prototype.isDarkMode=function(){return this._darkMode},t.prototype.refreshImmediately=function(t){this._disposed||this._refresh({animUpdate:!t,refresh:!0,refreshHover:!1})},t.prototype._refresh=function(t){t.animUpdate&&this.animation.update(!0),this._needsRefresh=this._needsRefreshHover=!1,this.painter.refresh({refresh:t.refresh,refreshHover:t.refreshHover}),this._needsRefresh=this._needsRefreshHover=!1},t.prototype.refresh=function(){this._disposed||(this._needsRefresh=!0,this.animation.start())},t.prototype.flush=function(){this._disposed||this._flush(!0)},t.prototype._flush=function(t){var e,n=or(),i=this._needsRefresh,r=this._needsRefreshHover;(i||r)&&(e=!0,this._refresh({animUpdate:t,refresh:i,refreshHover:r}));var o=or();e?(this._stillFrameAccum=0,this.trigger("rendered",{elapsedTime:o-n})):this._sleepAfterStill>0&&(this._stillFrameAccum++,this._stillFrameAccum>this._sleepAfterStill&&this.animation.stop())},t.prototype.setSleepAfterStill=function(t){this._sleepAfterStill=t},t.prototype.wakeUp=function(){this._disposed||(this.animation.start(),this._stillFrameAccum=0)},t.prototype.refreshHover=function(){this._needsRefreshHover=!0},t.prototype.refreshHoverImmediately=function(){this._disposed||this._refresh({animUpdate:!1,refresh:!1,refreshHover:!0})},t.prototype.resize=function(t){this._disposed||(t=t||{},this.painter.resize(t.width,t.height),this.handler.resize())},t.prototype.clearAnimation=function(){this._disposed||this.animation.clear()},t.prototype.getWidth=function(){if(!this._disposed)return this.painter.getWidth()},t.prototype.getHeight=function(){if(!this._disposed)return this.painter.getHeight()},t.prototype.setCursorStyle=function(t){this._disposed||this.handler.setCursorStyle(t)},t.prototype.findHover=function(t,e){if(!this._disposed)return this.handler.findHover(t,e)},t.prototype.on=function(t,e,n){return this._disposed||this.handler.on(t,e,n),this},t.prototype.off=function(t,e){this._disposed||this.handler.off(t,e)},t.prototype.trigger=function(t,e){this._disposed||this.handler.trigger(t,e)},t.prototype.clear=function(){if(!this._disposed){for(var t=this.storage.getRoots(),e=0;e<t.length;e++)t[e]instanceof vo&&t[e].removeSelfFromZr(this);this.storage.delAllRoots(),this.painter.clear()}},t.prototype.dispose=function(){var t;this._disposed||(this.animation.stop(),this.clear(),this.storage.dispose(),this.painter.dispose(),this.handler.dispose(),this.animation=this.storage=this.painter=this.handler=null,this._disposed=!0,t=this.id,delete mo[t])},t}();function bo(t,e){var n=new _o(I(),t,e);return mo[n.id]=n,n}function wo(t,e){yo[t]=e}function So(t){if("function"==typeof xo)return xo(t)}function Mo(t){xo=t}var Io=Object.freeze({__proto__:null,init:bo,dispose:function(t){t.dispose()},disposeAll:function(){for(var t in mo)mo.hasOwnProperty(t)&&mo[t].dispose();mo={}},getInstance:function(t){return mo[t]},registerPainter:wo,getElementSSRData:So,registerSSRDataGetter:Mo,version:"6.1.0"}),To=1e-4;var Co=Math.min,Do=Math.max,Ao=Math.abs,ko=Math.round,Lo=Math.floor,Po=Math.ceil,Oo=Math.pow,Ro=Math.log,No=Math.LN10,Eo=Math.PI,zo=Math.random;function Bo(t,e,n,i){var r=e[0],o=e[1],a=n[0],s=n[1],l=o-r,u=s-a;if(0===l)return 0===u?a:(a+s)/2;if(i)if(l>0){if(t<=r)return a;if(t>=o)return s}else{if(t>=r)return a;if(t<=o)return s}else{if(t===r)return a;if(t===o)return s}return(t-r)/l*u+a}var Vo=function(t,e,n){switch(t){case"center":case"middle":t="50%";break;case"left":case"top":t="Line truncated
|
||||
@@ -0,0 +1,6 @@
|
||||
.DS_Store
|
||||
node_modules/
|
||||
output/
|
||||
.playwright-cli/
|
||||
*.log
|
||||
*.tmp
|
||||
@@ -0,0 +1,131 @@
|
||||
# PolyForm Noncommercial License 1.0.0
|
||||
|
||||
<https://polyformproject.org/licenses/noncommercial/1.0.0>
|
||||
|
||||
## Acceptance
|
||||
|
||||
In order to get any license under these terms, you must agree
|
||||
to them as both strict obligations and conditions to all
|
||||
your licenses.
|
||||
|
||||
## Copyright License
|
||||
|
||||
The licensor grants you a copyright license for the
|
||||
software to do everything you might do with the software
|
||||
that would otherwise infringe the licensor's copyright
|
||||
in it for any permitted purpose. However, you may
|
||||
only distribute the software according to [Distribution
|
||||
License](#distribution-license) and make changes or new works
|
||||
based on the software according to [Changes and New Works
|
||||
License](#changes-and-new-works-license).
|
||||
|
||||
## Distribution License
|
||||
|
||||
The licensor grants you an additional copyright license
|
||||
to distribute copies of the software. Your license
|
||||
to distribute covers distributing the software with
|
||||
changes and new works permitted by [Changes and New Works
|
||||
License](#changes-and-new-works-license).
|
||||
|
||||
## Notices
|
||||
|
||||
You must ensure that anyone who gets a copy of any part of
|
||||
the software from you also gets a copy of these terms or the
|
||||
URL for them above, as well as copies of any plain-text lines
|
||||
beginning with `Required Notice:` that the licensor provided
|
||||
with the software. For example:
|
||||
|
||||
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
||||
|
||||
## Changes and New Works License
|
||||
|
||||
The licensor grants you an additional copyright license to
|
||||
make changes and new works based on the software for any
|
||||
permitted purpose.
|
||||
|
||||
## Patent License
|
||||
|
||||
The licensor grants you a patent license for the software that
|
||||
covers patent claims the licensor can license, or becomes able
|
||||
to license, that you would infringe by using the software.
|
||||
|
||||
## Noncommercial Purposes
|
||||
|
||||
Any noncommercial purpose is a permitted purpose.
|
||||
|
||||
## Personal Uses
|
||||
|
||||
Personal use for research, experiment, and testing for
|
||||
the benefit of public knowledge, personal study, private
|
||||
entertainment, hobby projects, amateur pursuits, or religious
|
||||
observance, without any anticipated commercial application,
|
||||
is use for a permitted purpose.
|
||||
|
||||
## Noncommercial Organizations
|
||||
|
||||
Use by any charitable organization, educational institution,
|
||||
public research organization, public safety or health
|
||||
organization, environmental protection organization,
|
||||
or government institution is use for a permitted purpose
|
||||
regardless of the source of funding or obligations resulting
|
||||
from the funding.
|
||||
|
||||
## Fair Use
|
||||
|
||||
You may have "fair use" rights for the software under the
|
||||
law. These terms do not limit them.
|
||||
|
||||
## No Other Rights
|
||||
|
||||
These terms do not allow you to sublicense or transfer any of
|
||||
your licenses to anyone else, or prevent the licensor from
|
||||
granting licenses to anyone else. These terms do not imply
|
||||
any other licenses.
|
||||
|
||||
## Patent Defense
|
||||
|
||||
If you make any written claim that the software infringes or
|
||||
contributes to infringement of any patent, your patent license
|
||||
for the software granted under these terms ends immediately. If
|
||||
your company makes such a claim, your patent license ends
|
||||
immediately for work on behalf of your company.
|
||||
|
||||
## Violations
|
||||
|
||||
The first time you are notified in writing that you have
|
||||
violated any of these terms, or done anything with the software
|
||||
not covered by your licenses, your licenses can nonetheless
|
||||
continue if you come into full compliance with these terms,
|
||||
and take practical steps to correct past violations, within
|
||||
32 days of receiving notice. Otherwise, all your licenses
|
||||
end immediately.
|
||||
|
||||
## No Liability
|
||||
|
||||
***As far as the law allows, the software comes as is, without
|
||||
any warranty or condition, and the licensor will not be liable
|
||||
to you for any damages arising out of these terms or the use
|
||||
or nature of the software, under any kind of legal claim.***
|
||||
|
||||
## Definitions
|
||||
|
||||
The **licensor** is the individual or entity offering these
|
||||
terms, and the **software** is the software the licensor makes
|
||||
available under these terms.
|
||||
|
||||
**You** refers to the individual or entity agreeing to these
|
||||
terms.
|
||||
|
||||
**Your company** is any legal entity, sole proprietorship,
|
||||
or other kind of organization that you work for, plus all
|
||||
organizations that have control over, are under the control of,
|
||||
or are under common control with that organization. **Control**
|
||||
means ownership of substantially all the assets of an entity,
|
||||
or the power to direct its management and policies by vote,
|
||||
contract, or otherwise. Control can be direct or indirect.
|
||||
|
||||
**Your licenses** are all the licenses granted to you for the
|
||||
software under these terms.
|
||||
|
||||
**Use** means anything you do with the software requiring one
|
||||
of your licenses.
|
||||
@@ -0,0 +1,304 @@
|
||||
# Lieflat Charts
|
||||
|
||||
[中文](README.md) | English
|
||||
|
||||
[](https://moxt.ai/hub?view=skill&id=lieflat-charts)
|
||||
|
||||
Lieflat Charts is an Agent Skills-compatible data visualization and report-generation skill for moxt, Claude Code, Codex, and other AI agents that support `SKILL.md`. Created at [moxt.ai](https://moxt.ai), it defaults to polished charts; it switches to one of 12 full-page templates, each available in Chinese and English, only when the user explicitly asks for a report, annual report, monthly report, white paper, poster, brief, or similar narrative deliverable.
|
||||
|
||||
Its visual language is built around consistent typography, spacing, line work, and motion. It includes three main chart families:
|
||||
|
||||
- **Lupi Editorial**: fine lines, dot fields, record-level detail, annotations, and generous whitespace for papers, long-form articles, annual reports, and slow-reading data stories.
|
||||
- **Glance**: bold bars, large numbers, blocks, and clear ranking for reports, dashboards, and situations where readers need the answer in seconds.
|
||||
- **Lupi Basics**: familiar bar, line, area, donut, and scatter silhouettes rebuilt with countable units, hairlines, and editorial typography.
|
||||
|
||||
The skill also includes standalone interactive visualizations for networks, paths, and dense multi-segment flows. Each chart aims to preserve honest data units while treating headlines, annotations, sources, and page structure as part of the visualization.
|
||||
|
||||
Mono is the reliable fallback, but color does not require an explicit user request. The agent can choose automatically between Mono, Porcelain, Palm, and Wire based on the data structure and publishing context; when the fit is unclear, it returns to Mono. When users provide brand colors or exact values, the skill can build one custom palette. One HTML file or chart set uses one color system only while preserving structure, contrast, and data meaning.
|
||||
|
||||
## Preview
|
||||
|
||||
Representative templates from each chart family.
|
||||
|
||||
### Lupi Editorial
|
||||
|
||||
Detailed, record-level, and editorial. Selected examples from 20 narrative templates.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/assets/preview-lupi-01.png" alt="Lupi Editorial preview one" width="100%"></td>
|
||||
<td width="50%"><img src="docs/assets/preview-lupi-02.png" alt="Lupi Editorial preview two" width="100%"></td>
|
||||
</tr>
|
||||
<tr><td colspan="2"><img src="docs/assets/preview-lupi-03.png" alt="Lupi Editorial preview three" width="100%"></td></tr>
|
||||
</table>
|
||||
|
||||
### Glance
|
||||
|
||||
Fast reading, pre-aggregated information, and conclusion-first composition. Selected examples from 22 Glance templates.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/assets/preview-glance-01.png" alt="Glance preview one" width="100%"></td>
|
||||
<td width="50%"><img src="docs/assets/preview-glance-02.png" alt="Glance preview two" width="100%"></td>
|
||||
</tr>
|
||||
<tr><td colspan="2"><img src="docs/assets/preview-glance-03.png" alt="Glance preview three" width="100%"></td></tr>
|
||||
</table>
|
||||
|
||||
Motion preview:
|
||||
|
||||
<p align="center"><img src="docs/assets/glance-motion.gif" alt="Glance motion preview" width="82%"></p>
|
||||
|
||||
More motion examples:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%"><img src="docs/assets/glance-wave-motion.gif" alt="Fifty markets motion preview" width="100%"><br><strong>Fifty markets</strong></td>
|
||||
<td width="33%"><img src="docs/assets/glance-race-motion.gif" alt="Eight products race motion preview" width="100%"><br><strong>Eight products race</strong></td>
|
||||
<td width="33%"><img src="docs/assets/glance-stroke-motion.gif" alt="H1 revenue motion preview" width="100%"><br><strong>H1 revenue</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### Lupi Basics
|
||||
|
||||
Familiar chart forms built from countable visual units. Selected examples from 17 foundational templates.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/assets/preview-basics-01.png" alt="Lupi Basics preview one" width="100%"></td>
|
||||
<td width="50%"><img src="docs/assets/preview-basics-02.png" alt="Lupi Basics preview two" width="100%"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### Interactive
|
||||
|
||||
For networks, paths, and high-density relationship data.
|
||||
|
||||
Motion preview:
|
||||
|
||||
<p align="center"><img src="docs/assets/interactive-motion.gif" alt="Interactive visualization preview" width="82%"></p>
|
||||
|
||||
[Open the Force Graph template to try dragging and zooming](https://larashero3-dotcom.github.io/lieflat-charts/templates/big-force.html)
|
||||
|
||||
## Added Color Mode
|
||||
|
||||
The skill can automatically choose Mono or one color preset from the data structure and publishing context; users do not need to request color first. Porcelain suits ordered or single-series data, Palm suits a small number of unordered categories, and Wire suits a restrained composition with one focal point. When the fit is unclear, the skill returns to Mono. Users who provide brand colors or exact values can use one custom palette. Each HTML file or chart set still locks one color system, with contrast, hierarchy, and data meaning checked after every change.
|
||||
|
||||
#### Porcelain
|
||||
|
||||
A single-hue blue scale for ordered data and single-series charts.
|
||||
|
||||
<p align="center"><img src="docs/assets/color-porcelain-motion.gif" alt="Porcelain Barcode Lollipop motion preview" width="100%"></p>
|
||||
|
||||
<p align="center"><img src="docs/assets/color-porcelain-almanac-motion.gif" alt="Porcelain Eight Years of Tickets, One Almanac motion preview" width="100%"></p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/assets/preview-color-porcelain-basics.png" alt="Porcelain Basics color preview" width="100%"><br><strong>Basics</strong></td>
|
||||
<td width="50%"><img src="docs/assets/preview-color-porcelain-glance.png" alt="Porcelain Glance color preview" width="100%"><br><strong>Glance</strong></td>
|
||||
</tr>
|
||||
<tr><td colspan="2"><img src="docs/assets/preview-color-porcelain.png" alt="Porcelain Lupi Editorial color preview" width="100%"><br><strong>Lupi Editorial</strong></td></tr>
|
||||
</table>
|
||||
|
||||
#### Palm
|
||||
|
||||
A low-saturation green and yellow family for a small number of unordered categories.
|
||||
|
||||
<p align="center"><img src="docs/assets/color-palm-wave-motion.gif" alt="Palm Fifty Markets, One Wave motion preview" width="100%"></p>
|
||||
|
||||
<p align="center"><img src="docs/assets/color-palm-support-motion.gif" alt="Palm Support Load, Day by Day motion preview" width="100%"></p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/assets/preview-color-palm-basics.png" alt="Palm Basics color preview" width="100%"><br><strong>Basics</strong></td>
|
||||
<td width="50%"><img src="docs/assets/preview-color-palm-glance.png" alt="Palm Glance color preview" width="100%"><br><strong>Glance</strong></td>
|
||||
</tr>
|
||||
<tr><td colspan="2"><img src="docs/assets/preview-color-palm.png" alt="Palm Lupi Editorial color preview" width="100%"><br><strong>Lupi Editorial</strong></td></tr>
|
||||
</table>
|
||||
|
||||
#### Wire
|
||||
|
||||
A black and gray palette with one fluorescent orange focal point.
|
||||
|
||||
<p align="center"><img src="docs/assets/color-wire-patchwork-motion.gif" alt="Wire A Quarter of Deploys, Overlaid motion preview" width="100%"></p>
|
||||
|
||||
<p align="center"><img src="docs/assets/color-wire-hourglass-motion.gif" alt="Wire The Funnel, Poured motion preview" width="100%"></p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/assets/preview-color-wire-basics.png" alt="Wire Basics color preview" width="100%"><br><strong>Basics</strong></td>
|
||||
<td width="50%"><img src="docs/assets/preview-color-wire-glance.png" alt="Wire Glance color preview" width="100%"><br><strong>Glance</strong></td>
|
||||
</tr>
|
||||
<tr><td colspan="2"><img src="docs/assets/preview-color-wire.png" alt="Wire Lupi Editorial color preview" width="100%"><br><strong>Lupi Editorial</strong></td></tr>
|
||||
</table>
|
||||
|
||||
## Latest Update
|
||||
|
||||
### Added Report Mode
|
||||
|
||||
Lieflat Charts can now generate complete HTML reports in addition to individual charts. The 12 full-page templates are available in Chinese and English for research reports, research briefs, business data reports, financial and economic analysis, product records, dashboards, posters, and personal datasets such as sports, travel, and yearly life logs. Template names describe the layout's character, not a hard use-case restriction; the same layout can move across report types when the information structure fits.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="25%"><img src="docs/assets/reports/en/report-03.png" alt="Report Template 03 Annual Data Report / Poster" width="100%"><br><strong>R03 · Annual Data Report / Poster</strong></td>
|
||||
<td width="25%"><img src="docs/assets/reports/en/report-09.png" alt="Report Template 09 Business Data / Financial Dashboard" width="100%"><br><strong>R09 · Business Data / Financial Dashboard</strong></td>
|
||||
<td width="25%"><img src="docs/assets/reports/en/report-12.png" alt="Report Template 12 Periodic Data Brief / Monitoring Summary" width="100%"><br><strong>R12 · Periodic Data Brief / Monitoring Summary</strong></td>
|
||||
<td width="25%"><img src="docs/assets/reports/en/report-08.png" alt="Report Template 08 Population / Socioeconomic One-Pager" width="100%"><br><strong>R08 · Population / Socioeconomic One-Pager</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="25%"><img src="docs/assets/reports/en/report-01.png" alt="Report Template 01 Research Report / One-Pager" width="100%"><br><strong>R01 · Research Report / One-Pager</strong></td>
|
||||
<td width="25%"><img src="docs/assets/reports/en/report-05.png" alt="Report Template 05 Project / Product Impact Story" width="100%"><br><strong>R05 · Project / Product Impact Story</strong></td>
|
||||
<td width="25%"><img src="docs/assets/reports/en/report-10.png" alt="Report Template 10 Personal Data / Sports / Travel Record" width="100%"><br><strong>R10 · Personal Data / Sports / Travel Record</strong></td>
|
||||
<td width="25%"><img src="docs/assets/reports/en/report-07.png" alt="Report Template 07 Research / Market Data Collage Poster" width="100%"><br><strong>R07 · Research / Market Data Collage Poster</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="25%"><img src="docs/assets/reports/en/report-02.png" alt="Report Template 02 Annual Review / Performance Recap" width="100%"><br><strong>R02 · Annual Review / Performance Recap</strong></td>
|
||||
<td width="25%"><img src="docs/assets/reports/en/report-11.png" alt="Report Template 11 Research / Financial Brief Card" width="100%"><br><strong>R11 · Research / Financial Brief Card</strong></td>
|
||||
<td width="25%"><img src="docs/assets/reports/en/report-04.png" alt="Report Template 04 Monthly Business / Financial Report" width="100%"><br><strong>R04 · Monthly Business / Financial Report</strong></td>
|
||||
<td width="25%"><img src="docs/assets/reports/en/report-06.png" alt="Report Template 06 Long-Cycle Product / Business Almanac" width="100%"><br><strong>R06 · Long-Cycle Product / Business Almanac</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Recommended: Use It in Moxt
|
||||
|
||||
[](https://moxt.ai/hub?view=skill&id=lieflat-charts)
|
||||
|
||||
Lieflat Charts was designed, tested, and continuously refined in [Moxt](https://moxt.ai/hub?view=skill&id=lieflat-charts). Its design rules, template structure, and file workflow were shaped through repeated use with Moxt's Agent collaboration model.
|
||||
|
||||
In Moxt, an Agent can more smoothly read the complete design specification, understand the visual language, apply the appropriate templates, and keep previewing and refining the result in the same workspace. This makes the Lieflat Charts design more consistent in practice.
|
||||
|
||||
| Lieflat Charts workflow | A typical one-shot chat | In Moxt |
|
||||
|---|---|---|
|
||||
| Understanding the design language | The Agent rebuilds its understanding from the Skill files | The Skill was designed and validated in the same Agent environment |
|
||||
| Reading rules, templates, and data | Files or paths may need to be supplied repeatedly | Rules, templates, data, and output remain together in one workspace |
|
||||
| Iterating on charts | Context may need to be explained again in a new conversation | The Agent can continue from the files and context already in the workspace |
|
||||
| Producing the final result | Output may remain in a chat or temporary directory | The HTML result stays beside its data and templates for further refinement |
|
||||
|
||||
Lieflat Charts still works with other tools that support Agent Skills. Moxt is its native production environment and the recommended path for the most complete workflow with the fewest setup steps.
|
||||
|
||||
### Install in Other Agents
|
||||
|
||||
Install with one command:
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/larashero3-dotcom/lieflat-charts --skill lieflat-charts
|
||||
```
|
||||
|
||||
You can also send the following instruction to an AI agent with shell access:
|
||||
|
||||
```text
|
||||
Install lieflat-charts. Clone https://github.com/larashero3-dotcom/lieflat-charts
|
||||
to ~/.claude/skills/lieflat-charts, then verify that SKILL.md, templates/,
|
||||
catalog.md, and mono-tokens.js are present.
|
||||
```
|
||||
|
||||
For Codex, replace the installation path with `~/.codex/skills/lieflat-charts`.
|
||||
|
||||
To update an existing installation:
|
||||
|
||||
```text
|
||||
Update lieflat-charts. Enter ~/.claude/skills/lieflat-charts, run git pull,
|
||||
and report the latest commit.
|
||||
```
|
||||
|
||||
After installation, ask your agent:
|
||||
|
||||
```text
|
||||
Turn this research dataset into five charts for a long-form article.
|
||||
Compare Lupi Editorial and Lupi Basics first. Use Glance only if neither group fits.
|
||||
```
|
||||
|
||||
More prompt examples:
|
||||
|
||||
```text
|
||||
Use lieflat-charts to turn this dataset into a color chart.
|
||||
```
|
||||
|
||||
```text
|
||||
Read this paper, identify the strongest data findings, and build a complete HTML chart page.
|
||||
```
|
||||
|
||||
```text
|
||||
This is weekly reporting data. Make the ranking, changes, and anomalies readable within ten seconds.
|
||||
```
|
||||
|
||||
```text
|
||||
Turn this CSV into a Glance chart suitable for a presentation.
|
||||
```
|
||||
|
||||
```text
|
||||
Redesign this dataset in the Lupi style, preserving every real record and adding useful annotations.
|
||||
```
|
||||
|
||||
```text
|
||||
Rebuild this chart with the Porcelain preset. Use lightness to represent value without changing the structure.
|
||||
```
|
||||
|
||||
The number of charts follows the number of independent findings: one chart for one question, two or three charts for two or three findings, and four to six charts for a complete article or paper. A single page defaults to no more than six charts, and repeated conclusions are removed rather than added to meet a quota.
|
||||
|
||||
## Templates
|
||||
|
||||
| Family | Count | Best for | Implementation |
|
||||
|---|---:|---|---|
|
||||
| **Lupi Editorial** | 15 | Annual reports, papers, long-form articles, posters, portfolios, and readers willing to inspect detail | Handwritten SVG |
|
||||
| **Lupi Basics** | 13 | Bars, lines, areas, donuts, scatterplots, waterfalls, heatmaps, progress, treemaps, and other foundational data shapes | Handwritten SVG / ECharts |
|
||||
| **Glance** | 18 | Weekly reports, dashboards, monitoring, and presentations that require fast comparison | Chart.js / ECharts |
|
||||
| **Interactive** | 3 | Networks, paths, multi-segment flows, and high-density relationship data | ECharts / SVG |
|
||||
| **Color Presets** | 3 families / 15 samples | Distinguishing real data dimensions or adding one controlled focal point to monochrome charts | Restyled original templates |
|
||||
| **Report Templates** | 12 templates / two languages | Research, annual, monthly, dashboard, poster, brief, and notebook-style full-page reports | Single-file HTML |
|
||||
|
||||
### Lupi Editorial
|
||||
|
||||
Each point, line, and annotation should map to a real unit whenever possible. Lupi Editorial does not rush to aggregate the evidence into a single number. It lays out records, distributions, structures, and exceptions through hairlines, whitespace, ledger-like guides, annotations, and low-contrast value scales.
|
||||
|
||||
### Lupi Basics
|
||||
|
||||
Lupi Basics retains familiar chart silhouettes while rebuilding them inside the same editorial language. A cell can represent one percentage point, a tick can represent one person, a hairline can represent one day, and a treemap rectangle can represent one honest weight. It is suited to smaller datasets that still need density and countable visual units.
|
||||
|
||||
### Glance
|
||||
|
||||
Glance pre-aggregates information, strengthens the main forms, and places the key ranking or change in the first visual pass. It is not a simplified Lupi mode. It serves a different reading speed: readers can identify what is higher, what changed most, and what needs attention within seconds.
|
||||
|
||||
### Interactive
|
||||
|
||||
Interactive templates handle relationship data that ordinary static charts cannot carry. Hover, focus, dragging, pinned paths, and status readouts turn complex networks into records that can be queried one by one. Interaction is reserved for real data, not decorative elements.
|
||||
|
||||
## Design
|
||||
|
||||
Every family shares the same core visual language: paper gray and charcoal at the extremes, a controlled grayscale ladder between them, and data encoded through lightness, position, length, density, and structure. The three color presets provide stable starting points. Explicit brand colors can also become one role-based custom palette. When users refine the palette, contrast, hierarchy, and data meaning still need to remain clear.
|
||||
|
||||
Lieflat Charts differs from a conventional chart generator in more than color:
|
||||
|
||||
- It identifies the data contract before choosing a chart.
|
||||
- Each chart carries one independent conclusion before charts are assembled into a page.
|
||||
- Real data units become visual atoms instead of using decorative noise to imitate density.
|
||||
- Headlines, annotations, sources, spacing, and motion are treated as part of the chart.
|
||||
- Lupi and Glance represent different reading speeds, not simply static versus interactive output.
|
||||
|
||||
## Structure
|
||||
|
||||
```text
|
||||
.
|
||||
├── README.md # Chinese project guide
|
||||
├── README.en.md # English project guide
|
||||
├── SKILL.md # Agent workflow and design rules
|
||||
├── catalog.md # Data-contract index for 49 chart types
|
||||
├── report-catalog.md # Scenario index for 12 report templates
|
||||
├── mono-tokens.js # Shared monochrome design tokens
|
||||
├── color-presets.js # Three built-in color presets
|
||||
├── templates/ # Lupi, Basics, Glance, interactive, and report templates
|
||||
│ ├── color/ # Color-restyled samples
|
||||
│ └── reports/ # 12 report templates, each in Chinese and English
|
||||
├── examples/ # Examples based on public datasets
|
||||
├── docs/assets/ # README screenshots and motion previews
|
||||
└── scripts/validate.mjs # Pre-release validation
|
||||
```
|
||||
|
||||
Open the HTML files under `templates/` directly to inspect the galleries. Open `templates/reports/index.html` to browse the report templates and their Chinese/English variants. Report mode chooses a full-page skeleton from `report-catalog.md`, then reuses the real chart implementations indexed in `catalog.md` for each chart slot. Lupi and Basics mainly use native SVG, while F13 Treemap uses ECharts. Glance, Circular, Force, and report templates R11/R12 also load Chart.js or ECharts from a CDN and require an internet connection unless those dependencies are inlined.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [PolyForm Noncommercial License 1.0.0](LICENSE). Learning, modification, sharing, and noncommercial use are allowed. Commercial use requires separate permission.
|
||||
|
||||
Chart.js, Apache ECharts, and the Inter typeface remain subject to their original licenses. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
||||
@@ -0,0 +1,304 @@
|
||||
# Lieflat Charts
|
||||
|
||||
中文 | [English](README.en.md)
|
||||
|
||||
[](https://moxt.ai/zh-CN/hub?view=skill&id=lieflat-charts)
|
||||
|
||||
Lieflat Charts 是一套遵循 Agent Skills 格式的数据可视化与报告生成 skill,可供 moxt、Claude Code、Codex 及其他兼容 `SKILL.md` 的 AI agent 使用。本 skill 在 [moxt.ai](https://moxt.ai) 制作,默认把数据做成有编辑感的图表;只有用户明确要求报告、年报、月报、白皮书、海报或 brief 时,才从 12 套中英文整页模板生成可发布的 HTML 报告。
|
||||
|
||||
它以统一的字体、留白、线条和动效建立自己的视觉语法,包括以下几种视觉风格:
|
||||
|
||||
- **Lupi(编辑叙事型)**:用细线、点阵、逐条记录和大量留白展开数据,强调真实单位、细节和旁注,适合论文、长文、年报与需要慢慢阅读的数据故事。
|
||||
- **Glance(快速判断型)**:用粗柱、大数字、色块和清晰排序提前聚合信息,让读者几秒内看懂高低、变化和异常,适合周报、汇报与 dashboard。
|
||||
- **Basics(基础编辑型)**:保留柱状图、折线图、环形图等熟悉轮廓,再用可数刻度、发丝线和编辑排版增加质感,适合结构简单或数据量较少的内容。
|
||||
|
||||
此外还提供网络、路径和多段流向等独立交互大图。每张图都尽量保留数据的真实单位,同时让标题、旁注、来源和页面结构参与表达。
|
||||
|
||||
Mono 黑白灰是稳定的保底方案,同时也有彩色模式,目前支持青瓷蓝、椰林绿和编辑部红三种彩色色系,方便适配各种场景的数据可视化。Agent 会根据数据结构和使用场景,在 Mono、青瓷蓝、椰林绿或编辑部红之间自动选择;适配关系不明确时使用 Mono。用户明确提供品牌色或色值时,也可以建立一套 custom 色板。同一份 HTML 或同一组图只使用一种色彩系统,生成后仍可继续调色,同时保持图型结构、比例、对比度和数据契约。
|
||||
|
||||
## Preview
|
||||
|
||||
以下是几类模板的实际预览。
|
||||
|
||||
### Lupi Editorial
|
||||
|
||||
细读、逐记录、编辑感。精选 20 张编辑叙事型模板中的代表图型。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/assets/preview-lupi-01.png" alt="Lupi 编辑型预览一" width="100%"></td>
|
||||
<td width="50%"><img src="docs/assets/preview-lupi-02.png" alt="Lupi 编辑型预览二" width="100%"></td>
|
||||
</tr>
|
||||
<tr><td colspan="2"><img src="docs/assets/preview-lupi-03.png" alt="Lupi 编辑型预览三" width="100%"></td></tr>
|
||||
</table>
|
||||
|
||||
### Glance
|
||||
|
||||
快读、聚合、结论先行。精选 22 张快速判断型模板中的代表图型。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/assets/preview-glance-01.png" alt="Glance 快读型预览一" width="100%"></td>
|
||||
<td width="50%"><img src="docs/assets/preview-glance-02.png" alt="Glance 快读型预览二" width="100%"></td>
|
||||
</tr>
|
||||
<tr><td colspan="2"><img src="docs/assets/preview-glance-03.png" alt="Glance 快读型预览三" width="100%"></td></tr>
|
||||
</table>
|
||||
|
||||
动态预览:
|
||||
|
||||
<p align="center"><img src="docs/assets/glance-motion.gif" alt="Glance 动态预览" width="82%"></p>
|
||||
|
||||
更多动态预览:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%"><img src="docs/assets/glance-wave-motion.gif" alt="Fifty markets 动态预览" width="100%"><br><strong>Fifty markets</strong></td>
|
||||
<td width="33%"><img src="docs/assets/glance-race-motion.gif" alt="Eight products race 动态预览" width="100%"><br><strong>Eight products race</strong></td>
|
||||
<td width="33%"><img src="docs/assets/glance-stroke-motion.gif" alt="H1 revenue 动态预览" width="100%"><br><strong>H1 revenue</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### Lupi Basics
|
||||
|
||||
常见图型与可数单位的结合。17 张模板覆盖柱、线、面积、环形、散点、矩形树图、直方图、箱线图、K 线等基础数据形状。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/assets/preview-basics-01.png" alt="Lupi 基础型预览一" width="100%"></td>
|
||||
<td width="50%"><img src="docs/assets/preview-basics-02.png" alt="Lupi 基础型预览二" width="100%"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### Interactive
|
||||
|
||||
用于网络、路径与高密度关系数据。
|
||||
|
||||
动态预览:
|
||||
|
||||
<p align="center"><img src="docs/assets/interactive-motion.gif" alt="Interactive 动态预览" width="82%"></p>
|
||||
|
||||
[打开 Force Graph 模板体验拖拽与缩放](https://larashero3-dotcom.github.io/lieflat-charts/templates/big-force.html)
|
||||
|
||||
## 增加了彩色模式
|
||||
|
||||
图表可以根据数据结构和使用场景自动选择 Mono 或一套彩色预设,不要求用户先说“要彩色”。有序单序列可使用青瓷蓝,少量无序类目可使用椰林绿,需要一个受控视线落点时可使用编辑部红;适配关系不明确时回到 Mono。用户明确给出品牌色或色值时,可以建立一套 custom 色板。同一份 HTML 或同一组图只使用一种色彩系统;调整时需重新检查对比度、视觉主次和颜色所表达的数据含义。
|
||||
|
||||
#### Porcelain · 青瓷蓝
|
||||
|
||||
单色相明度阶,适合有序数据和单序列。
|
||||
|
||||
<p align="center"><img src="docs/assets/color-porcelain-motion.gif" alt="青瓷蓝 Barcode Lollipop 动态预览" width="100%"></p>
|
||||
|
||||
<p align="center"><img src="docs/assets/color-porcelain-almanac-motion.gif" alt="青瓷蓝 Eight Years of Tickets, One Almanac 动态预览" width="100%"></p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/assets/preview-color-porcelain-basics.png" alt="青瓷蓝 Basics 彩色图表预览" width="100%"><br><strong>Basics</strong></td>
|
||||
<td width="50%"><img src="docs/assets/preview-color-porcelain-glance.png" alt="青瓷蓝 Glance 彩色图表预览" width="100%"><br><strong>Glance</strong></td>
|
||||
</tr>
|
||||
<tr><td colspan="2"><img src="docs/assets/preview-color-porcelain.png" alt="青瓷蓝 Lupi 彩色图表预览" width="100%"><br><strong>Lupi Editorial</strong></td></tr>
|
||||
</table>
|
||||
|
||||
#### Palm · 椰林绿
|
||||
|
||||
低饱和绿黄色系,用色相区分少量无序类目。
|
||||
|
||||
<p align="center"><img src="docs/assets/color-palm-wave-motion.gif" alt="椰林绿 Fifty Markets, One Wave 动态预览" width="100%"></p>
|
||||
|
||||
<p align="center"><img src="docs/assets/color-palm-support-motion.gif" alt="椰林绿 Support Load, Day by Day 动态预览" width="100%"></p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/assets/preview-color-palm-basics.png" alt="椰林绿 Basics 彩色图表预览" width="100%"><br><strong>Basics</strong></td>
|
||||
<td width="50%"><img src="docs/assets/preview-color-palm-glance.png" alt="椰林绿 Glance 彩色图表预览" width="100%"><br><strong>Glance</strong></td>
|
||||
</tr>
|
||||
<tr><td colspan="2"><img src="docs/assets/preview-color-palm.png" alt="椰林绿 Lupi 彩色图表预览" width="100%"><br><strong>Lupi Editorial</strong></td></tr>
|
||||
</table>
|
||||
|
||||
#### Wire · 编辑部红
|
||||
|
||||
黑灰阶加一个荧光橙视线落点。
|
||||
|
||||
<p align="center"><img src="docs/assets/color-wire-patchwork-motion.gif" alt="编辑部红 A Quarter of Deploys, Overlaid 动态预览" width="100%"></p>
|
||||
|
||||
<p align="center"><img src="docs/assets/color-wire-hourglass-motion.gif" alt="编辑部红 The Funnel, Poured 动态预览" width="100%"></p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/assets/preview-color-wire-basics.png" alt="编辑部红 Basics 彩色图表预览" width="100%"><br><strong>Basics</strong></td>
|
||||
<td width="50%"><img src="docs/assets/preview-color-wire-glance.png" alt="编辑部红 Glance 彩色图表预览" width="100%"><br><strong>Glance</strong></td>
|
||||
</tr>
|
||||
<tr><td colspan="2"><img src="docs/assets/preview-color-wire.png" alt="编辑部红 Lupi 彩色图表预览" width="100%"><br><strong>Lupi Editorial</strong></td></tr>
|
||||
</table>
|
||||
|
||||
## 最新更新
|
||||
|
||||
### 增加了报告模式
|
||||
|
||||
现在可以在单张图表之外,直接从 12 套整页报告模板生成 HTML 报告。每套模板都提供中文版和英文版,覆盖调研报告、研究简报、业务数据报告、财报与金融经济分析、产品记录、dashboard、海报,以及运动、旅行和年度生活数据记录等从工作到个人的需求。模板名称代表版式性格,不是使用场景的限制;同一套模板可以根据数据结构迁移到不同类型的报告。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="25%"><img src="docs/assets/reports/report-03.png" alt="报告模板 03 年度数据报告 / 年度海报" width="100%"><br><strong>R03 · 年度数据报告 / 年度海报</strong></td>
|
||||
<td width="25%"><img src="docs/assets/reports/report-09.png" alt="报告模板 09 业务数据 / 财务经营 Dashboard" width="100%"><br><strong>R09 · 业务数据 / 财务经营 Dashboard</strong></td>
|
||||
<td width="25%"><img src="docs/assets/reports/report-12.png" alt="报告模板 12 周期数据快报 / 监控摘要" width="100%"><br><strong>R12 · 周期数据快报 / 监控摘要</strong></td>
|
||||
<td width="25%"><img src="docs/assets/reports/report-08.png" alt="报告模板 08 人群与社会经济数据一页" width="100%"><br><strong>R08 · 人群 / 社会经济数据一页</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="25%"><img src="docs/assets/reports/report-01.png" alt="报告模板 01 调研报告 / 研究一页" width="100%"><br><strong>R01 · 调研报告 / 研究一页</strong></td>
|
||||
<td width="25%"><img src="docs/assets/reports/report-05.png" alt="报告模板 05 项目 / 产品影响力故事" width="100%"><br><strong>R05 · 项目 / 产品影响力故事</strong></td>
|
||||
<td width="25%"><img src="docs/assets/reports/report-10.png" alt="报告模板 10 个人数据 / 运动 / 旅行记录" width="100%"><br><strong>R10 · 个人数据 / 运动 / 旅行记录</strong></td>
|
||||
<td width="25%"><img src="docs/assets/reports/report-07.png" alt="报告模板 07 调研 / 市场数据拼贴海报" width="100%"><br><strong>R07 · 调研 / 市场数据拼贴海报</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="25%"><img src="docs/assets/reports/report-02.png" alt="报告模板 02 年度复盘 / 业绩回顾" width="100%"><br><strong>R02 · 年度复盘 / 业绩回顾</strong></td>
|
||||
<td width="25%"><img src="docs/assets/reports/report-11.png" alt="报告模板 11 研究 / 金融经济简报卡" width="100%"><br><strong>R11 · 研究 / 金融经济简报卡</strong></td>
|
||||
<td width="25%"><img src="docs/assets/reports/report-04.png" alt="报告模板 04 月度业务 / 财务经营报告" width="100%"><br><strong>R04 · 月度业务 / 财务经营报告</strong></td>
|
||||
<td width="25%"><img src="docs/assets/reports/report-06.png" alt="报告模板 06 长周期产品 / 业务年鉴" width="100%"><br><strong>R06 · 长周期产品 / 业务年鉴</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 零门槛快速使用
|
||||
|
||||
### 推荐在 Moxt 中使用
|
||||
|
||||
[](https://moxt.ai/zh-CN/hub?view=skill&id=lieflat-charts)
|
||||
|
||||
Lieflat Charts 在 [Moxt](https://moxt.ai/zh-CN/hub?view=skill&id=lieflat-charts) 中完成设计、测试和持续迭代。它的设计规则、模板结构与文件工作流,都是围绕 Moxt 的 Agent 协作方式反复打磨的。
|
||||
|
||||
因此,在 Moxt 中使用时,Agent 能更顺畅地读取完整的设计规范、理解 Lieflat Charts 的视觉语言、调用对应模板,并在同一个工作区中持续预览和修改结果,更稳定地执行这套设计。
|
||||
|
||||
| Lieflat Charts 的工作环节 | 普通的一问一答方式 | 在 Moxt 中 |
|
||||
|---|---|---|
|
||||
| 理解设计语言 | 需要从 Skill 文件重新建立理解 | Skill 已在相同的 Agent 工作环境中完成设计和验证 |
|
||||
| 读取规则、模板和数据 | 通常需要反复上传文件或提供路径 | 规则、模板、数据和成品可以保留在同一个工作区 |
|
||||
| 多轮选择和修改图表 | 更换对话后可能需要重新说明背景 | Agent 可以沿用工作区中的文件和上下文继续修改 |
|
||||
| 生成最终结果 | 结果可能停留在对话或临时目录中 | HTML 成品可以和数据、模板一起留在工作区继续完善 |
|
||||
|
||||
Lieflat Charts 仍然可以安装到其他支持 Agent Skills 的工具中;Moxt 是它的原生制作环境,也是目前更完整、步骤更短的推荐使用方式。
|
||||
|
||||
### 安装到其他 Agent
|
||||
|
||||
一条命令安装:
|
||||
|
||||
```bash
|
||||
npx skills add https://github.com/larashero3-dotcom/lieflat-charts --skill lieflat-charts
|
||||
```
|
||||
|
||||
也可以直接把这段话发给有 shell 权限的 AI Agent:
|
||||
|
||||
```text
|
||||
帮我安装 lieflat-charts。请把 https://github.com/larashero3-dotcom/lieflat-charts
|
||||
克隆到 ~/.claude/skills/lieflat-charts,安装完成后检查 SKILL.md、templates/、
|
||||
catalog.md 和 mono-tokens.js 是否存在。
|
||||
```
|
||||
|
||||
使用 Codex 时,将安装路径换成 `~/.codex/skills/lieflat-charts`。
|
||||
|
||||
已经安装过的话,用这段话更新:
|
||||
|
||||
```text
|
||||
帮我更新 lieflat-charts。请进入 ~/.claude/skills/lieflat-charts 执行 git pull,
|
||||
然后告诉我当前最新 commit。
|
||||
```
|
||||
|
||||
安装后直接对 Agent 说:
|
||||
|
||||
```text
|
||||
把这份调研数据做成适合公众号长文的 5 张中文版图表。
|
||||
默认先比较 Lupi Editorial 和 Lupi Basics 候选;两组都不适配时,再使用 Glance。
|
||||
```
|
||||
|
||||
也可以试这些请求:
|
||||
|
||||
```text
|
||||
帮我用 lieflat charts 给这些数据做个彩色风格的图表。
|
||||
```
|
||||
|
||||
```text
|
||||
读这篇论文,找出最值得讲的几个数据结论,做成一页完整的 HTML 图表。
|
||||
```
|
||||
|
||||
```text
|
||||
这是一份周报数据,要求 10 秒内看懂排名、变化和异常。
|
||||
```
|
||||
|
||||
```text
|
||||
把这个 CSV 做成一张适合放进汇报里的 Glance 图表。
|
||||
```
|
||||
|
||||
```text
|
||||
用 Lupi 风格重新设计这组数据,保留每条真实记录,并加入必要的旁注。
|
||||
```
|
||||
|
||||
```text
|
||||
用青瓷蓝预设重做这张图,用明度深浅表示数值大小,不改变原图的结构。
|
||||
```
|
||||
|
||||
图数由独立结论决定:单个问题通常 1 张,两个到三个结论 2–3 张,完整文章或论文 4–6 张,单页默认最多 6 张。用户明确指定数量时会遵守,但不会为了凑数重复表达同一个结论。
|
||||
|
||||
## Templates
|
||||
|
||||
| 类型 | 数量 | 适合什么 | 实现 |
|
||||
|---|---:|---|---|
|
||||
| **Lupi Editorial** | 15 | 年报、论文、公众号、海报、作品集;读者愿意停下来细看 | 手写 SVG |
|
||||
| **Lupi Basics** | 13 | 柱、折线、面积、环形、散点、瀑布、热力、进度、矩形树图等基础数据形状 | 手写 SVG / ECharts |
|
||||
| **Glance** | 18 | 周报、dashboard、监控、汇报;需要快速排序和比较 | Chart.js / ECharts |
|
||||
| **Interactive** | 3 | 网络、路径、多段流向和高密度关系数据 | ECharts / SVG |
|
||||
| **Color Presets** | 3 套 / 15 个样张 | 需要颜色区分数据维度,或为 Mono 加一个受控视线落点 | 基于原模板换肤 |
|
||||
| **Report Templates** | 12 套 / 中英双版 | 调研、年报、月报、仪表盘、海报、简报和个人记录等完整整页报告 | 单文件 HTML |
|
||||
|
||||
### Lupi Editorial
|
||||
|
||||
把一个点、一根线或一条旁注尽量对应到真实数据单位。它不急着把数据聚合成一个结论,而是把原材料摊开,让读者看到结构、分布和例外。视觉上使用发丝线、留白、账本式导轨、旁注和低对比灰阶,阅读时间通常在 30 秒以上。
|
||||
|
||||
### Lupi Basics
|
||||
|
||||
保留常见图表的剪影,但把它们放进 Lupi 的编辑语法里:一格可以是一个百分点,一根 tick 可以是一个人,一条 hairline 可以是一天,Treemap 的一块面积可以对应一个真实权重。它适合数据不多、但仍然希望画面有密度和可读单位的场景。
|
||||
|
||||
### Glance
|
||||
|
||||
提前聚合、加粗主要形状,把关键排序和变化放到第一眼。它不是“低配版 Lupi”,而是另一种阅读速度:读者不需要展开每条记录,也能在几秒内知道谁更高、哪里变化最大、哪个指标需要关注。
|
||||
|
||||
### Interactive
|
||||
|
||||
用于普通静态图承载不了的关系数据。通过 hover、聚焦、拖拽、固定路径和状态栏,把“看起来很复杂”的网络变成可以逐条查询的图。交互只服务于真实记录,不给纯装饰元素添加假的行为。
|
||||
|
||||
## Design
|
||||
|
||||
所有体系共享一套 Mono 视觉语法:纸灰与炭黑两极,加上中间灰阶;明度承担层级,位置、长度、密度和结构承担数据编码。三套彩色预设提供稳定的配色起点;用户明确给出品牌色时,也可以建立角色完整、对比度合格的 custom 色板。继续调色时,仍需保证视觉主次和数据含义清楚。创新不在于再发明一种孤立图型,而在于把图型选择、编辑排版、浏览器交互和整页叙事放进同一个可复用的 skill。
|
||||
|
||||
因此,Lieflat Charts 和过去直接做 charts 的差别,不只是“换了颜色”:
|
||||
|
||||
- 先判断数据契约,再选图型,而不是先挑一个库内模板
|
||||
- 每张图先承担一个独立结论,再组成整页,而不是把所有字段都画上去
|
||||
- 把真实数据单位作为视觉原子,不用装饰性噪声伪造密度
|
||||
- 把标题、旁注、来源、留白和动效视为图表的一部分
|
||||
- 用 Lupi 和 Glance 表达两种阅读速度,而不是把静态图和交互图当成唯一分类
|
||||
|
||||
## Structure
|
||||
|
||||
```text
|
||||
.
|
||||
├── README.md # 中文项目说明
|
||||
├── README.en.md # English project guide
|
||||
├── SKILL.md # Agent 使用的工作流与规则
|
||||
├── catalog.md # 49 个图型的数据契约索引
|
||||
├── report-catalog.md # 12 套整页报告模板的场景索引
|
||||
├── mono-tokens.js # 共享视觉 token
|
||||
├── color-presets.js # 三套内置彩色预设
|
||||
├── templates/ # Lupi、Basics、Glance、交互与报告模板
|
||||
│ ├── color/ # 彩色换肤样张
|
||||
│ └── reports/ # 12 套报告模板,每套中英文双版
|
||||
├── examples/ # 真实公开数据案例
|
||||
├── docs/assets/ # README 模板截图与动态预览
|
||||
└── scripts/validate.mjs # 发布前检查
|
||||
```
|
||||
|
||||
直接打开 `templates/` 下的 HTML 文件即可查看 gallery;打开 `templates/reports/index.html` 可浏览报告模板并进入中英文版本。报告模式先从 `report-catalog.md` 选整页骨架,再为各图表槽位复用 `catalog.md` 中的真实图型。Lupi 和 Basics 主要使用原生 SVG,F13 Treemap 使用 ECharts;Glance、Circular、Force 以及报告模板 R11/R12 通过 CDN 加载 Chart.js 或 ECharts,需要联网才能完整显示。
|
||||
|
||||
## License
|
||||
|
||||
本项目使用 [PolyForm Noncommercial License 1.0.0](LICENSE)。允许学习、修改、分享和非商业使用;商业使用需要另行取得许可。
|
||||
|
||||
Chart.js、Apache ECharts 和 Inter 字体遵循各自的原始许可证,详见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
|
||||
@@ -0,0 +1,355 @@
|
||||
---
|
||||
name: lieflat-charts
|
||||
description: 一套模板驱动的数据可视化与报告生成 skill,既能严格从 Lupi、Basics、Glance、Maps 与 Interactive gallery 的真实实现生成 HTML 图表,也能从 12 套中英文整页报告模板生成可发布的 HTML 报告;以 Mono 为保底,能按数据语义自动选择内置彩色预设,也支持用户明确提供的自定义色板。地图仅在用户明确要求时启用,同一交付禁止混用色系。
|
||||
---
|
||||
|
||||
# Lieflat Charts — 图表品味法典
|
||||
|
||||
Lieflat Charts 是一套遵循 Agent Skills 格式的数据可视化与报告生成 skill,以 Mono 灰阶为保底,也会在数据语义和使用场景明确适配时自动选择一套彩色预设。本 skill 在 [moxt.ai](https://moxt.ai) 制作,支持 Lupi 编辑叙事型、Glance 快速判断型、Basics 基础编辑型、Maps 地图、Interactive 交互大图,以及 `report-catalog.md` 中的 12 套中英文整页报告模板。用户给你数据和场合,可以得到一个无需构建、双击可打开的单文件 HTML 图表或 HTML 报告。纯 SVG 图可离线运行;使用 Chart.js、ECharts、地图 GeoJSON 或在线字体的图,在未内联依赖时需要联网。**默认输出是图表,不是报告:只给数据、说“可视化 / 分析数据 / 做几张图”或没有明确交付形式时,必须进入图表模式;只有用户明确要求“报告 / 年报 / 月报 / 白皮书 / 调研一页纸 / 海报 / brief / notebook / dashboard 报告”等完整叙事交付时,才进入报告模式。** 图表模式下**必须先从 Lupi Editorial 和 Lupi Basics 中选型;只有两者都没有合适模板,或用户明确要求 Glance / dashboard / 三秒快读时,才允许使用 Glance。Maps 只有在用户明确说要地图或地域分布时才启用。**
|
||||
|
||||
颜色无需用户显式触发。按第六点五节在 Mono 与 `color-presets.js` 的三套内置方案之间自动判断;用户明确给出品牌色或自定义色值时,可以建立一套交付内的 custom 色板。同一份 HTML 或同一组交付只能锁定一种色彩系统,不得混用。换肤样张在 `templates/color/`。
|
||||
|
||||
**怎么查一张图的参考代码**:catalog 查到图型 → 打开对应 gallery 文件 → 按卡内标题找到 `<div class="card">` 块看结构 → 在 `<script>` 里搜同名 `// ════` 注释块拿渲染代码。不要整页照抄——gallery 是多卡合页,交付给用户的永远是按第九节骨架组装的单图文件。
|
||||
|
||||
**目标用户是非程序员**(写作者、运营、做 PPT 的人)。他们说人话("帮我把这季度转化画一下,发公众号用"),不说图型名。你的职责是把人话翻译成正确的图,而且好看到能直接发出去。
|
||||
|
||||
---
|
||||
|
||||
## 零、输出模式与模板优先硬约束
|
||||
|
||||
### 0.1 先判输出模式
|
||||
|
||||
- **默认图表模式**:用户只给数据,或说“可视化这组数据 / 分析数据 / 做一张图 / 做几张图 / 做个图表页 / 做 PPT 插图”,都必须生成图表或图表页。没有报告关键词时,不得自行套用 R01–R12,也不得因为数据很丰富、结论很多或用户说“分析”就升级成报告。
|
||||
- **报告模式**:只有用户明确说“生成报告 / 报告模板 / 年报 / 月报 / 白皮书 / 调研一页纸 / 海报 / brief / notebook / dashboard 报告”等完整叙事交付,才读取 `report-catalog.md`,按报告类型、内容结构、版心、密度、阅读速度和语言选择一个 R01–R12。目录里的场景只是召回线索,不是对模板的限制。
|
||||
- **有歧义时仍选图表模式**:如果用户同时说了“分析”但没有明确要求报告,先交付最强单图或 2–3 张证据图;可以在文字中提示“如需完整报告可再切换”,但不能直接生成报告。
|
||||
- **不要把报告模式当成“多画几张图”**。报告模板决定整页结构;页内图表仍必须遵守 `catalog.md` 和本文件的图表规则。
|
||||
- 报告模板提供 `.zh.html` 与 `.en.html` 两个版本。用户没有指定语言时,跟随输入语言;中英文内容不能混在同一版本中。
|
||||
|
||||
以下规则不是建议,违反任意一条都必须返工:
|
||||
|
||||
1. **必须从仓库模板生成。** 每张成品图先在 `catalog.md` 锁定图型编号,再打开对应 gallery 的真实实现:Lupi 使用 `templates/lupi-gallery.html`,基础型使用 `templates/basics-gallery.html`,Glance 使用 `templates/glance-gallery.html`,地图使用 `templates/maps-gallery.html`,交互大图使用 `templates/big-*.html`。彩色图仍以这些原版模板为结构正本,`templates/color/` 只用于查配色。
|
||||
2. **必须沿用所选模板的代码骨架。** 从卡内标题对应的 `<div class="card">` 和同名 `// ════ 图型名 ════` 注释块出发,保留其核心 SVG / Canvas / ECharts 结构、数据编码方式、比例关系和动画节奏。允许替换数据、标题、旁注、来源与必要布局;禁止脱离模板另画一张“看起来差不多”的图,禁止拼接多个模板造混合图型,禁止退回图表库默认样式。
|
||||
3. **默认选型顺序固定。** 先完整比较 Lupi Editorial(L1–L20),再比较 Lupi Basics(F1–F17)。只要其中存在能诚实承载数据、容纳标签且可读的模板,就必须从这两组中选择。Maps 不进入这条默认链,只有用户明确要求地图时才单独检查。
|
||||
3.1 **两组之内还分主力与后备。** 主力是 **L1–L15 与 F1–F13**,默认从这里出图。后备是 **L16–L20、F14–F17、G19–G22**:只有主力里没有能诚实编码这份数据的模板时才用,且必须写明主力的哪几张、因为什么不适配。理由不得只写“新图更好看”或“更专业”。唯一例外见第 3.2 条。
|
||||
3.2 **五种数据形状允许直接用后备图,不必先证伪主力。** 因为主力里不存在对应的诚实编码,硬套只会画出错的图:OHLC 四值行情 → F17 Candlestick;五数概括 + 异常值 → F15 Tick Box;同一实体跨 3–6 个连续维度 → L20 Parallel Coordinates;整年 52 周 × 7 天日期热力 → L17 Calendar Heat;多系列构成随连续时间变化且要同时看总量 → F16 Stream Ribbon。除这五种,后备图一律走 3.1 条。
|
||||
4. **Glance 是默认降级方案,不是并列首选。** 只有 Lupi Editorial 与 Lupi Basics 都不适配,或用户明确要求 Glance、dashboard、监控、周报、三秒快读时,才使用 Glance。降级前必须写明 Lupi / Basics 不适配的具体原因。
|
||||
5. **库外新造是最后手段。** Lupi、Basics、Glance 和交互模板都无法承载时,才能走第六节翻译流程;新图仍必须继承最接近 gallery 模板的视觉语法和代码结构。
|
||||
|
||||
## 一、工作流程(每次请求都走这六步)
|
||||
|
||||
1. **判数据形状。** 别问用户要什么图,看他的数据长什么样:几个类目的比较?带时间的序列?占比?带正负?多对一归属?网络?逐条记录的分布?形状是选图的主键。
|
||||
2. **先审计主力,再考虑后备。** 按数据形状先扫描主力 L1–L15 与 F1–F13 的候选,至少比较 3 个;不足 3 个就列出全部。比较语义契合、单位诚实、标签容纳、阅读速度、叙事张力和本批次是否重复。主力全部不适配时,才扫描后备 L16–L20 / F14–F17,并写明主力哪几张为什么不行(第零节 3.1)。数据形状命中第零节 3.2 那五种时,直接用对应后备图。
|
||||
3. **必要时才检查 Glance。** 只有 Lupi 与 Basics 候选全部失败,或用户明确要求 Glance / dashboard / 监控 / 周报 / 三秒快读,才扫描 Glance(G1–G22)。选择 Glance 时记录为什么 Lupi 与 Basics 无法承载,不得只写“Glance 更直观”。用户明确要求地图时跳到 Maps(M1–M2),不把地图混入普通候选。
|
||||
4. **锁定真实模板,再组织整页。** 每张图必须记录体系、图型编号、gallery 文件和卡内标题,并以该卡的真实结构与渲染代码为骨架。禁止先想好“这一页要讲六件事”,再临时发明图型;整页叙事只能在模板锁定之后组织。
|
||||
5. **按图数规则组成批次。** 一张图只承担一个独立结论;去掉重复结论后,再按第 1.2 节的默认区间决定数量。模板需要全局分配:不重复、不堆同一种轮廓、不为了凑数硬加图。
|
||||
6. **按模板渲染并自检**(第零、二、三、八节)。逐项核对成品是否仍能对应到所选 gallery 实现;不能因为改数据而换掉模板的核心几何、编码或动效。按第六点五节为整份交付选择 Mono 或一套彩色预设;库外图型走翻译流程(第六节)。
|
||||
|
||||
### 1.1 报告模式工作流
|
||||
|
||||
1. 读取 `report-catalog.md`,至少比较 3 个报告候选并记录淘汰理由。按内容结构、信息密度、版心和阅读速度选模板,不把模板名称当成行业限制;例如“旅行手记”也可以承载运动或个人生活数据,“月度运营”也可以承载财报或业务数据报告。
|
||||
2. 锁定一个语言版本和一个报告模板文件;不得拼接两个报告模板的版式。
|
||||
3. 先提炼整页的主结论、证据结论、上下文说明和来源,再把它们分配到模板已有的标题、导语、KPI、图表、旁注和结尾槽位。
|
||||
4. 每个图表槽位单独按 `catalog.md` 选型,优先复用 Lupi / Basics 的真实实现;报告模板的布局不能成为绕过图表数据契约的理由。
|
||||
5. 复制对应报告模板的整份 HTML 作为起点,只替换数据、文案、来源、图例、语言和必要模块。不得保留演示数据、演示来源、Moxt 链接或模板原始结论。
|
||||
6. 报告页的色彩系统锁定为模板当前色系;如用户明确要求改色,整页统一替换为 Mono、单一内置预设或完整 custom 色板,不得局部混搭。
|
||||
7. 报告模式的交付前额外检查:版心尺寸未漂移、章节顺序仍成立、页内图表数量有证据支撑、定尺模板没有溢出、中文字体和英文 fallback 可读。
|
||||
|
||||
### 1.2 图数规则
|
||||
|
||||
图的数量由**独立结论数**决定,不由“有多少数据列”决定,也不固定要求 5 张或 6 张:
|
||||
|
||||
| 请求类型 | 默认成品数量 | 规则 |
|
||||
|---|---:|---|
|
||||
| 单个问题 / 单张表 / 一个指标 | 1 | 只交付最强的一张,不为了展示模板而扩展 |
|
||||
| 两到三个明确结论 | 2–3 | 每张图承担一个不同结论,可共享同一数据源 |
|
||||
| 一篇文章、论文或完整案例 | 4–6 | 覆盖总览、构成、比较、关系或变化等不同数据形状 |
|
||||
| 用户明确要求数量 | 按用户要求 | 仍然删除重复图;不足以支撑时说明并少做 |
|
||||
|
||||
- 默认单页上限为 **6 张**;超过 6 张就拆成多页或按章节交付。
|
||||
- 候选方案、Glance/Lupi 对照稿不计入最终图数;它们是选择过程,不是成品批次。
|
||||
- 一页多图至少保留一个总览结论,其余图必须提供新的比较维度、关系、时间变化或细节证据。
|
||||
- 如果两个图表达同一个结论,只保留阅读场合更合适、数据契约更诚实的一张。
|
||||
|
||||
## 二、Mono 语法 · 硬规则(违反即返工)
|
||||
|
||||
默认引用 `mono-tokens.js`(开源分发时把内容内联进 HTML)。与 token 冲突的取值一律以 token 为准。走彩色时只替换颜色 token,字体、圆角、布局和动画仍以 `mono-tokens.js` 为准。
|
||||
|
||||
**颜色**
|
||||
- 默认只有纸灰 `#F0EFEB` 和炭黑 `#1C1C1A` 两极,中间 7 级灰阶 ladder。彩色例外按第六点五节从预设起步。
|
||||
- **明度即数据**:最重要 = 最黑(暗卡上反转为最亮)。多系列按重要性沿 ladder 分配,不按顺序随便拿。
|
||||
- **一律实心**:不透明材质、不发光、不渐变滤镜、无阴影。质感全靠明度对比和形状。唯一例外:叠加型图(Radial Patchwork)里透明度本身编码密度——那是数据,不是装饰。
|
||||
- 暗卡(`.card.dark`)只给两种图:必须暗底衬托的形(花瓣、发光感丝线/网络)。默认浅卡;每屏(4 卡)最多 1 张暗卡。
|
||||
|
||||
**排版**
|
||||
- Inter 全家。标题 700 / 图内数值 800 / 轴标签 600。卡片结构固定四件套:结论式标题(h2)+ 副标题(图例和时间范围写在这里,用 `·` 分隔)+ 图 + 来源行(全大写、加字距)。
|
||||
- 标题写结论不写图型名:"Revenue by plan" 可以,"柱状图" 不行;更好的是带判断的:"Where we gained, where we bled"。
|
||||
- SVG 最小字号:半宽卡 6.5px、通栏 5.5px。装不下的信息改 hover 出,不许缩小字号硬塞。
|
||||
|
||||
**形状**
|
||||
- 卡片圆角 24px,无边框无阴影,靠留白分卡。柱端胶囊圆角(竖柱圆上端、横柱圆外端)。
|
||||
- **柱状图不断轴。** 柱的契约是长度∝数值,断轴毁契约。极端值场景的正确做法:①让极端值冲天(最诚实)②主图+放大镜小图 ③撕柱不撕轴(明说画不下)。
|
||||
- 花瓣皮(粗黑缝+圆角扇瓣)只适用等分或近似等分的径向图。宽窄悬殊的扇区会相互遮蔽,降低可读性。
|
||||
|
||||
**动画**
|
||||
- 入场动画默认开,`quarticOut` 快进快停,不弹跳(波浪入场可用 elasticOut)。点阵 stagger 8–15ms/个,条形 80–130ms/根。
|
||||
- 统一 reveal 机制:滚入视野才播 + 点击重播(用 token 里的 `obsReveal`,带 timer 清理)。
|
||||
- 必须带 `prefers-reduced-motion` 降级(token 的 CSS 已含)。
|
||||
- **动画不可优于结构**:一个效果如果需要发明新布局才能安放,不配存在(effectScatter 教训)。
|
||||
|
||||
**数据**
|
||||
- 演示数据用 token 的 `rnd(i,k)` 确定性伪随机,禁用 `Math.random()`——刷新必须长一样。
|
||||
- 数值和视觉严格成正比。面积编码用 `Math.sqrt(v)` 换算半径,不许拿数值直接当半径。
|
||||
|
||||
## 三、两个风格系的分工
|
||||
|
||||
同一份 mono 色板下有两种世界观,选哪个看**场合**和**读者愿意花几秒**:
|
||||
|
||||
| | Glance 系(22 张) | Lupi 系(细读,20 张) |
|
||||
|---|---|---|
|
||||
| 基本单元 | 形状(粗柱、大弧、色块) | 记录(一个点 = 一条数据) |
|
||||
| 线条 | 2px+,理直气壮 | 0.5–0.7px 发丝 |
|
||||
| 聚合 | 提前聚合好,给结论 | 拒绝聚合,摊开原材料 |
|
||||
| 读法 | 扫一眼(<10s) | 凑近读(30s+) |
|
||||
| 场合 | 周报、dashboard、随手贴 | 年报、对外故事页、海报 |
|
||||
| 引擎 | Chart.js / ECharts | 手写 SVG |
|
||||
|
||||
**默认策略:Lupi Editorial → Lupi Basics → Glance。** 没有明确场合时,禁止默认 Glance。放年报、公众号长文、海报、开源 README、作品集时先选 Lupi Editorial;数据较少或适合熟悉图型时再选 Lupi Basics。只有前两组都没有合适模板,或用户明确要 dashboard、监控、周报、三秒快读时,才进入 Glance 候选池。
|
||||
|
||||
**全量候选审计不是“把所有图都做一遍”。** 先用数据形状筛出能编码同一本体的候选,再把候选分成:
|
||||
|
||||
- **语义合适**:每个点、线、面、串珠背后都有真实单位或明确聚合口径。
|
||||
- **视觉合适**:标签装得下,密度足够,读者能按预期时间读完。
|
||||
- **叙事合适**:图形本身能承载这批数据要讲的判断,而不只是把数字摆出来。
|
||||
|
||||
最终选择是三项的交集,不是“哪个模板文件最容易复制”。默认审计先在 Lupi Editorial 与 Lupi Basics 内完成;只有两组都失败才扩展到 Glance。若同一批要做多张图,再对候选做一次全局分配:模板不重复,形状轮换,最多一张暗卡,避免整页变成六张相似的环或横条。
|
||||
|
||||
**数据少(只有几个百分比)不等于只能 Glance。** Lupi 化的正道是单位分解:把聚合数摊回可数单位(1 点 = 1 人 / 1 百分点),密度来自单位而不是记录数。单位含义写进副标题,只摊诚实单位(占比加总 100 → 100 个点),不编造不存在的个体记录。取整时如果加总不足 100(如 49.0+27.4+13.9+5.0+3.2 → 98),在底注写明「另外 N 人被四舍五入吃掉了」,不凑假单位。
|
||||
|
||||
**小数据走 Lupi 的路径优先级(经多轮案例验证)**:
|
||||
1. **先全量扫描,再选代码骨架**——基础型组 F1–F13(`templates/basics-gallery.html`)是稀疏数据的 Lupi 词汇:柱/折线/面积/环形/横条/分组/堆叠/散点/瀑布/热力/进度/哑铃/矩形树图;编辑型 L1–L15 则提供逐记录、单位分解、关系与旁注语法。这两组是主力,小数据几乎总能在这里落地。F14–F17 与 L16–L20 是后备,按第零节 3.1 / 3.2 判断。不要因为 `basics-gallery.html` 里有现成代码就直接选 F 图。
|
||||
2. **用最接近本体的模板起步**——多选题百分比优先比较 L15、F5、L2;100% 构成优先比较 L14、L5、F4、G4;漏斗优先比较 L13、F1/F5 的降级表达。选择依据是数据编码方式,不是文件顺序。
|
||||
3. **库里真没有对应形状才新造**,且新造必须从 gallery 现有语法延伸(发丝 tick、确定性 rnd 抖动、paint-order 光晕、全大写注记),不引入库外参照——“Lupi 风”指 gallery 那批图的视觉语法,不是 Giorgia Lupi 本人的手绘风。
|
||||
4. 新造的小数据图要配满**环境结构层**:gallery 好看有一半靠无数据的家具(账本纸横线、虚线导轨、rim 刻度、每 10 单位的立柱网格、旁注引线)。数据层诚实稀疏,密度预算花在家具上。只画数据 + 一根底线的小数据图必然寒酸。
|
||||
|
||||
**同一批产出(一页多图)里模板不重复。** 20 张 Lupi 系够轮换;同一数据能被多个模板承接时,选这一批里还没用过的。
|
||||
|
||||
## 四、图型决策树(数据形状 → 候选)
|
||||
|
||||
编号对应 `catalog.md`。这些只是**候选召回表**,箭头和书写顺序不代表优先级。实际选择必须遵守:先检查全部 Lupi Editorial 与 Lupi Basics 候选,确认无合适模板后,才允许采用 Glance 候选。
|
||||
|
||||
- **少类目比较(≤8)** → G3 Chunky Bars ⇄ 竖排 F1 Rung Bars / 横排 F5 Tick Rows ⇄ L2 Dot Cascade(⚠️ cascade 类目名竖排,仅当名称 ≤4 字或短缩写时可用;中文长类目换 F5/L5/L12)
|
||||
- **多选题百分比(各项独立 0–100,加总可超 100)** → G3 Chunky Bars ⇄ L15 Ballot Tally
|
||||
- **多类目分布(30–60 根)** → G12 Stagger Wave
|
||||
- **带正负的分类数值** → G10 Diverging Bar
|
||||
- **占比 / 100% 构成** → G4 Dot Waffle ⇄ L14 Hundred Field ⇄ F4 Tick Donut(饼图的默认替代);近似等分且要门面 → G2 Petal Rose;占比×强度双编码 → G13 Big Slice;按类堆叠 → F7 Stacked Rungs
|
||||
- **两时点对比(前后 / 今昔)** → 类目级(≤6 类)→ F12 Dumbbell Queue(横向,串珠 = 真单位)⇄ F6 Paired Rungs(并肩双梯);只有 2–4 条序列要画趋势线 → Glance chunky slope(粗线、大数、结论进标题)。交叉过多的斜率图会显著降低可读性,不要为了 Lupi 化增加装饰结构。
|
||||
- **日序列(≤30 天,带区间)** → G1 Range Capsules ⇄ 逐日读数 F2 Hairline Line ⇄(90 天级、要肌理)L3 Barcode Lollipop;30–60 天看形态 → F3 Hairline Area
|
||||
- **累计增长** → G18 Draw-in + Counter
|
||||
- **双序列因果(投入 vs 产出)** → G8 Rainfall
|
||||
- **实时数据** → G17 Dynamic Stream
|
||||
- **排名随时间变** → 需要动态演示用 G16 Bar Race;静态见下方「排名随离散时间变化」一条
|
||||
- **星期×小时×量** → G14 Single Axis ⇄ F10 Dot Heat
|
||||
- **瀑布 / 增减分解(≤6 级)** → F9 Rung Waterfall
|
||||
- **单值进度(0–100%)** → F11 Tick Gauge ⇄ G18 Draw-in + Counter
|
||||
- **二维散点(≤20 点)** → F8 Plumb Scatter;几百点分布 → G15 Jitter Strip
|
||||
- **单变量分箱频数** → 先看 F1 Rung Bars 能不能直接承接(bin 当类目);确实需要分箱语义时用 F14 Rung Histogram。bin 边界必须有业务含义;不要为了好看任意切箱。
|
||||
- **分组连续分布** → **五数概括 + 异常值直接用 F15 Tick Box**(主力无对应编码)。只看密度轮廓时先比较 G15 Jitter Strip;G19 Violin 与 L19 Ridgeline 属后备,要写明为什么 Jitter / F15 不够。
|
||||
- **逐条记录分布** → G15 Jitter Strip。只有每个点都必须保留且堆积形状本身有意义时,才降级到 L18 Beeswarm。蜂群不是默认优先。
|
||||
- **分类×分类+量(矩阵)** → 主力优先:轻量 L4 Arc Matrix,跨年份带旁注 L9 Bubble Almanac。两者都撑不住(格子多到气泡互相挤、必须靠明度读)才用 L16 Matrix Heat;要快读且每格直接标数才用 G20。
|
||||
- **全年日期×数量** → **整年 52 周 × 7 天直接用 L17 Calendar Heat**(主力无对应编码,L3 只到 90 天级)。星期×小时的重复周期仍用 F10 / G14。
|
||||
- **多对一归属** → 造型强 L5 Radial Convergence ⇄ 带名单 L12 Type Colonnade
|
||||
- **漏斗 / 分阶段递减** → L13 Hourglass Stream
|
||||
- **层级结构** → G7 Tree LR
|
||||
- **层级 + 份额 / 权重(两层、值为正数)** → F13 Nested Treemap;只看谁属于谁、不比较大小 → G7 Tree LR
|
||||
- **多系列构成随连续时间变化** → 静态类目构成先用 F7 Stacked Rungs;**要同时看总量与构成的连续时间流,直接用 F16 Stream Ribbon**(主力无对应编码)。只有单序列总量用 F3 / G17。
|
||||
- **同一实体跨 3–6 个连续维度** → **直接用 L20 Parallel Coordinates**(主力无对应编码)。维度超过 6 个先筛选或拆图。
|
||||
- **OHLC 行情** → **直接用 F17 Candlestick**(主力无对应编码)。只有 min–max 区间而无开收盘值时用 G1。
|
||||
- **两端聚合流量** → 先看 L5 Radial Convergence / L12 Type Colonnade 能否承接归属关系;确实要看流量宽度才用 G22 Aggregate Sankey。要查每条真实路径用 B3 Threads。
|
||||
- **排名随离散时间变化(静态)** → 先比较 L11 Trend Lineage / L2 Dot Cascade;确实要逐期格子对位才用 G21 Rank Strip。
|
||||
- **事件序列生命史** → L11 Trend Lineage
|
||||
- **多实体出生时间+现状** → L1 Launch Fan
|
||||
- **逐事件时刻分布(一天内)** → L10 Radial Patchwork
|
||||
- **双极量表(两端都合法)** → L7 Brand Spectrum。注意和单极打分(雷达)区分:单极用 ECharts 原生雷达
|
||||
- **网络** → ≤15 节点 G6/G11 小图;>15 或要查数 → B1 环形 / B2 力导向;多段路径流向 → B3 Threads
|
||||
- **多组×网格(要装饰感)** → L8 Dotty Matrix;要读数 → 摊平用 G14
|
||||
- **同一实体集多维度轮播(演示)** → G9 Scatter Morph
|
||||
|
||||
### 地图显式触发规则
|
||||
|
||||
- 只有用户明确说“地图”“地域分布”“按国家/州着色”或点名 choropleth,才检查 Maps。数据表里仅出现地区字段不得自动触发。
|
||||
- 美国州级区域数值用 M1 US Choropleth;世界国家级区域数值用 M2 World Choropleth。地图面积是地理面积,不代表数值,副标题必须说明 `shade = value`。
|
||||
- M1/M2 依赖 ECharts 与在线 GeoJSON;交付时说明需要联网,或在用户明确要求离线时再内联合法来源的地图数据。
|
||||
- 中国地图不从 M1/M2 改名套用。只有拿到符合当前地图合规要求的完整数据源和审图信息后才制作。
|
||||
|
||||
### F13 Nested Treemap 硬规则
|
||||
|
||||
- 只接收层级数据和非负权重;父级值由子级求和,禁止父子总数互相矛盾。面积直接交给 ECharts treemap 布局,不再对值开方。
|
||||
- 默认展示两层(父组 + 叶子)。超过两层且读者需要逐层查询时才开启 drill-down;普通静态交付保持 `nodeClick:false`,避免误触改变视图。
|
||||
- Mono 的灰度只表示层级,不给每个叶子随机分配深浅;父组靠标题带、组间留白和边界区分。去掉颜色后仍能看清分组。
|
||||
- porcelain 用单色相明度表示有序的父组份额或层级;palm 用色相对应不超过 4 个顶层类目;wire 保持灰阶,只允许一个明确主角使用 `HERO`。
|
||||
- 面积已经表达数值时,颜色不得再次无说明地重复同一数值。副标题必须写清 `area = ...`,使用彩色时再写清 `color = ...`。
|
||||
- 叶子超过 30 个、标签大面积省略,或最小矩形小到无法形成稳定热区时,先合并长尾为 Other、拆成多张,或改用 G7 / L12 / B 系关系图。
|
||||
- Tooltip 至少显示完整层级路径、原始值和总量占比;卡内标签放不下就隐藏,不缩到低于最小字号。
|
||||
|
||||
## 五、交互三问(决定静态还是可交互)
|
||||
|
||||
按顺序问:
|
||||
|
||||
1. **这根线/点背后有没有一条真实记录?** 没有(纯肌理装饰,如 Cluster Field 的辐条、Hourglass 的淌线)→ 禁止加交互,给没有内容的元素加 hover 是欺骗。
|
||||
2. 有记录 → **不点能不能读出来?** 元素 <50 且两端有标注(如 Colonnade)→ 静态够用,hover 是锦上添花。
|
||||
3. **元素 >50 根或多段路径**(如 Threads)→ 必须 hover/pin,否则只是氛围图。交互实现参考 `templates/big-threads.html`:可见线下垫 9px 透明孪生线当热区,hover 单线亮整条路径、hover 标签拉整束、点击钉住。
|
||||
|
||||
## 六、库外图型 · 翻译流程
|
||||
|
||||
用户要的图不在 64 张里(或发来一张参考图)时,**不是做不了,是现场造句**。流程四步,第一步不许跳:
|
||||
|
||||
1. **先回答本体**:这个图型编码了什么?每个视觉通道(位置/长度/角度/面积/明度/密度)分别对应哪个数据维度?答不出来就问用户拿数据结构。抄形不抄魂 = 白做(pictorial bar 曾把"符号计数"错做成"树长高",返工)。
|
||||
2. **找最近的亲戚**:在 catalog 里找数据形状最像的一张当起点,继承它的布局骨架和动画节奏。
|
||||
3. **用 token 造句**:色板、字体、圆角、动画参数全部从 `mono-tokens.js` 拿,不许发明新颜色新字号。产出必须和库里的图"一眼一家人"。走彩色时颜色改从 `color-presets.js` 的单一预设中取,其他 token 仍以 `mono-tokens.js` 为准。
|
||||
4. **过第二节硬规则和第八节自检**,和库内图同一标准。
|
||||
|
||||
参考图翻译的附加规则:识别参考图属于哪个流派(Glance or 编辑部),编辑部风格的密度、旁注、手绘感(`blob`)是本体的一部分,别做收敛了——almanac 曾因"不敢挤"返工。
|
||||
|
||||
## 六点五、彩色(按场景自动选择,单一色系)
|
||||
|
||||
**Mono 是保底方案,不是未触发彩色时的强制答案。** 用户没有提颜色时也要根据数据语义、类目数量、阅读场景和内容气质评估是否适合彩色;适配关系不明确,或颜色没有带来真实信息价值时,回到 Mono。
|
||||
|
||||
彩色初稿从三套预设中选择一套,色值在 `color-presets.js`,样张在 `templates/color/`。预设用于快速得到稳定、统一的第一版,不是锁死的色板。用户要求"这个蓝再深一点"时,在当前色系内继续调整;用户明确给出品牌色、色值或完整色板时,按下方规则建立 custom 色板。调整后必须重新检查对比度、视觉主次和数据语义。
|
||||
|
||||
**同一份 HTML 或同一组交付必须先锁定一个全局色彩系统:Mono、porcelain、palm、wire 或一套 custom。** 所有图共享该选择。若其中某张图不支持已选系统,不得单独换色;改选一套全局兼容的系统,或整组退回 Mono。
|
||||
|
||||
**结构正本永远是原版 gallery。** 查渲染代码去 `templates/` 根目录的 gallery,`templates/color/` 只查配色。先按第零节选定结构,再决定是否换肤。
|
||||
|
||||
### 用不用彩色
|
||||
|
||||
按以下优先级判断:
|
||||
|
||||
1. **用户明确指定。** 用户要求某种颜色、气质或预设时优先服从,但仍要通过容量、对比度和数据语义检查。
|
||||
2. **Agent 自动选择。** 即使用户没提颜色,只要数据形状和场景与某套预设有清楚的对应关系,就可以自动使用:有序单序列适合 porcelain;少量无序类目适合 palm;需要一个受控视线落点适合 wire。
|
||||
3. **关系不明确就用 Mono。** 类目过多、高密度记录、颜色没有稳定含义,或整组图无法共享同一预设时,使用 Mono。
|
||||
|
||||
彩色不是升级版,而是 Agent 可以主动选择的语义工具。交付时用一句话说明所选色系及其承担的数据含义。
|
||||
|
||||
### 自定义色板(只在用户明确要求时启用)
|
||||
|
||||
Agent 不得自行发明第五套默认审美。只有用户给出明确色值、品牌色、品牌规范或"沿用这张参考图的配色"时,才建立 custom 色板;仅说"好看一点"仍从三套内置预设选择。
|
||||
|
||||
1. **先确定颜色逻辑,再分配色值。** 有序数据用单色相明度梯;无序类目用分类色;需要聚焦时用中性色 + 一个强调色。禁止先拿到五个颜色,再平均撒到所有元素上。
|
||||
2. **建立完整角色,不直接散落 hex。** custom 至少定义 `BG`、`TXT`、`MUT`、`GRID`、`DATA`;需要重点时再定义 `HERO`,有序数据定义 `RAMP`,分类数据定义 `CAT`。成品内联一个 `CUSTOM` 对象,所有图从角色取色。
|
||||
3. **按输入数量解释。** 1 个颜色生成同色相明度梯;2 个颜色默认分为主数据色 + 强调色,不自动当作两个平等类目;3–6 个颜色只有在数据确有对应类目时才进入 `CAT`。超过 6 个先合并类目、改用位置/标签,或退回 Mono。
|
||||
4. **允许派生明暗,不允许偷渡新色相。** 浅色由用户色与 `BG` 混合,深色由用户色与 `TXT` 混合;不得借用 porcelain、palm、wire 的色值补齐 custom。
|
||||
5. **对比度是硬门。** 正文和小标签相对背景至少 4.5:1,大字至少 3:1;关键边界、数据形状和交互状态相对相邻颜色至少 3:1。不达标时优先调整明度,不擅自改变用户指定的主色相。
|
||||
6. **颜色不能成为唯一线索。** 分类仍带标签,序数仍保留位置/长度/面积,强调仍有标题或旁注说明。色觉异常用户去掉颜色后,图的结构仍应读得懂。
|
||||
7. **一次只锁定这一套 custom。** custom 不与任何内置预设混用;多图交付共享同一个 `CUSTOM`。除非用户明确要求把品牌色固化为项目级预设,否则 custom 只内联在当前成品,不回写 `color-presets.js`。
|
||||
|
||||
用户后续要求"再深一点"、"背景暖一点"或替换某个色值时,修改对应角色并重新检查整套对比度和语义映射,不只改被点名的那一个元素。
|
||||
|
||||
### 选哪套:先看数据形状,再看气质
|
||||
|
||||
| 数据形状 | 允许 | 原因 |
|
||||
|---|---|---|
|
||||
| 有序 / 单序列(进度、热力、时间序列、单指标、排名) | porcelain、wire | 明度即数据的契约仍然成立 |
|
||||
| 无序类目 ≤4 | palm | 色相对应类目 |
|
||||
| 无序类目 5–6 | palm(勉强) | 依靠明度和饱和度辅助区分 |
|
||||
| 类目 >6 | Mono 灰阶 | 彩色预设容量不足 |
|
||||
| 需要克制但要一个视线落点 | wire | 灰阶承载数据,橙色只标主角 |
|
||||
|
||||
| 用户会说 | 预设 | 气质 |
|
||||
|---|---|---|
|
||||
| 蓝、冷、学术、理性、严肃、科技感 | porcelain | 单色相蓝阶 |
|
||||
| 绿、暖、自然、温柔、复古、莫兰迪 | palm | 绿黄低饱和,琥珀点睛 |
|
||||
| 黑白、克制、杂志感、编辑部 | wire | 黑灰加一点荧光橙 |
|
||||
|
||||
### 彩色硬规则
|
||||
|
||||
- **同一交付只用一种色彩系统。** 一张图、同一份 HTML 或同一组姊妹图只能使用 Mono、`color-presets.js` 的一套预设,或一套用户明确要求的 custom。禁止预设彼此混用,也禁止 custom 借用内置预设补色。
|
||||
- **线宽 ×1.8、透明度地板 .85。** 灰阶下的发丝线换成淡色后容易消失;`dot heat` 除外。
|
||||
- **每张图至少 3 个色阶或色位。** 只用 1–2 个颜色时退回灰阶。
|
||||
- **颜色必须连接真实维度。** 序数梯接数值,分段色接月份或区段,分类色接类目;底注写清颜色在编码什么。
|
||||
- **强调色只给一个主角。** 第二个主角会消解强调。
|
||||
- **暗卡数据色必须亮于卡底。** 暗底大图使用 preset 的 `DARK` 块。`big-circular` / `big-force` / `big-threads` 只有 porcelain 和 palm;wire 使用原版 Mono 大图。
|
||||
- **palm 中 `DATA` 和 `HERO` 必须不同。** 琥珀和橄榄明度接近,不要把两者当作平等类目色。
|
||||
|
||||
## 七、什么时候说不
|
||||
|
||||
敢拒绝比什么都接更可信。以下情况不做,并给替代:
|
||||
|
||||
- **断轴柱状图** → 拒绝,给三个诚实方案(冲天 / 放大镜 / 撕柱不撕轴)。
|
||||
- **发光 / 玻璃拟态 / 3D** → 拒绝,保持现有视觉语法。自定义色值按 custom 规则映射;跨色彩系统混用拒绝。
|
||||
- **拿多色相画单序列** → 拒绝。给 porcelain、wire 或退回 Mono。
|
||||
- **类目 >6 还要彩色** → 拒绝,退回 Mono 灰阶。
|
||||
- **用户未明确要求地图** → 不因数据里有地区字段自动使用 M1/M2;继续按普通比较、排名或构成选图。
|
||||
- **地图范围超出 M1/M2**(中国省级、城市点位、轨迹图等)→ 不套用错误边界;确认数据源、投影、合规和交互要求后再现场制作。
|
||||
- **给纯装饰元素加交互**(第五节第 1 问)→ 拒绝并解释。
|
||||
- **雷达图重构** → 不重构,用 ECharts 原生雷达 + Mono token 换肤(实测过:展示场景里"认得出的图型"有价值)。
|
||||
- 数据太少撑不起所选图型(如 3 个节点要力导向)→ 降级到更简单的图并说明。
|
||||
|
||||
## 八、交付前自检清单
|
||||
|
||||
1. 数值和视觉成正比?(面积用了 sqrt?柱没断轴?)
|
||||
2. 色板是否受控?整份 HTML 或同一组交付是否只使用 Mono、单一内置预设或单一 custom?Mono 中出现任何非 ladder 颜色即返工;内置彩色出现另一套预设颜色即返工;custom 是否只从 `CUSTOM` 角色取色并通过对比度检查?
|
||||
3. 标签会不会重叠?(barcode 教训:邻近峰值标签要强制最小间距)
|
||||
4. 最小字号踩线没有?(半宽 6.5 / 通栏 5.5)
|
||||
5. 演示数据是 `rnd` 确定性的?刷新两次长得一样?
|
||||
6. reveal 正常?(滚入播、点击重播、timer 不叠加、reduced-motion 降级在)
|
||||
7. `node --check` 过语法(把 `<script>` 内容抽出来查)。
|
||||
8. 卡片四件套齐全?标题是结论不是图型名?
|
||||
9. 副标题把图例说清了?(读者不看代码只看这一行)
|
||||
10. 是否完成全量候选审计?至少写下 3 个候选和淘汰理由,而不是只记录“采用了哪个模板”。
|
||||
11. 如果是一页多图,模板是否全局分配过?有没有相似轮廓重复、暗卡过量、所有图都变成同一种径向图?
|
||||
12. 每张图是否记录了图型编号、gallery 文件和卡内标题?成品的核心结构是否确实来自该模板,而不是重新发明?
|
||||
13. 如果使用 Glance,是否写明 Lupi Editorial 与 Lupi Basics 为什么都不适配?如果没有,返工回到 Lupi / Basics。
|
||||
14. 最后一问:这张图放回所选 gallery 的对应卡片旁边,是不是同一个模板家族,而不只是“风格有点像”?
|
||||
|
||||
## 九、单文件模板骨架
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Mono — {图名}</title>
|
||||
<!-- 需要 ECharts 时: -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@6/dist/echarts.min.js"></script>
|
||||
<!-- 需要 Chart.js 时(G1 / G3): -->
|
||||
<!-- <script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script> -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="{MONO.FONT.link}" rel="stylesheet">
|
||||
<style>/* 内联 MONO.CARD_CSS */</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="grid2">
|
||||
<div class="card"><!-- 或 card dark / card wide -->
|
||||
<h2>{结论式标题}</h2>
|
||||
<div class="sub">{说明} · {图例} · {时间范围}</div>
|
||||
<!-- 图容器按引擎三选一: -->
|
||||
<div class="ch" id="ch"></div><!-- ECharts -->
|
||||
<!-- 手写 SVG: <svg id="ch" viewBox="0 0 400 320"></svg> -->
|
||||
<!-- Chart.js(G1/G3): <div class="wrap"><canvas id="ch"></canvas></div>,配 .wrap{position:relative;height:320px} -->
|
||||
<!-- ⚠️ Chart.js 必须挂 <canvas>,套 <div class="ch"> 会报 can't acquire context -->
|
||||
<div class="src">{图型名} · {系列} · {数据来源}</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// 内联 mono-tokens.js 全文
|
||||
// ── 数据(用户只需要改这里)──
|
||||
const DATA = [ /* ... */ ];
|
||||
// ── 渲染 ──
|
||||
MONO.obsReveal('ch', el => { /* ... */ });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## 十、报告模式骨架
|
||||
|
||||
```text
|
||||
report-catalog.md # 先按场景选 R01–R12
|
||||
templates/reports/report-NN.zh.html # 中文整页正本
|
||||
templates/reports/report-NN.en.html # English full-page source
|
||||
templates/reports/index.html # 本地可打开的模板索引
|
||||
docs/assets/reports/report-NN.png # 中文版静态预览
|
||||
```
|
||||
|
||||
报告交付仍然是单文件 HTML;`templates/reports/` 中的文件是可复制的起始骨架,不是需要逐字照抄的内容样例。模板内的演示数据只用于展示结构,生成最终报告时必须全部替换。
|
||||
@@ -0,0 +1,11 @@
|
||||
# 第三方依赖说明
|
||||
|
||||
Lieflat Charts 的部分模板会在浏览器中加载以下第三方项目。它们不受本仓库的 PolyForm Noncommercial License 约束,仍分别遵循自己的许可证。
|
||||
|
||||
| 项目 | 用途 | 许可证 |
|
||||
|---|---|---|
|
||||
| [Chart.js](https://github.com/chartjs/Chart.js) | Glance 图表渲染 | MIT License |
|
||||
| [Apache ECharts](https://github.com/apache/echarts) | Glance 与交互网络图渲染 | Apache License 2.0 |
|
||||
| [Inter](https://github.com/rsms/inter) | 页面字体 | SIL Open Font License 1.1 |
|
||||
|
||||
当前模板通过 jsDelivr 和 Google Fonts 加载这些资源。分发经过修改的第三方代码、字体文件或离线打包版本时,请同时保留对应项目要求的许可证和版权声明。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Lieflat Charts"
|
||||
short_description: "Template-driven charts and bilingual HTML reports"
|
||||
default_prompt: "Use $lieflat-charts to turn this data into polished HTML charts by default. Only generate a complete bilingual-ready report when the user explicitly asks for a report, annual/monthly report, white paper, poster, brief, or similar narrative deliverable. Reuse the closest chart or report templates with one coherent color system."
|
||||
@@ -0,0 +1,108 @@
|
||||
# Lieflat Charts 图型目录 · 64 张
|
||||
|
||||
> 每张图挂三个标签:**数据形状**(选图的主键)、**场合**、**读者时间**。
|
||||
> **主力与后备:** 主力是 L1–L15 与 F1–F13,默认从这里出图。L16–L20、F14–F17、G19–G22 是后备,只有主力无法诚实编码这份数据时才用,并要写明理由。例外是 F15、F16、F17、L17、L20 这五张——主力里不存在对应编码,命中相应数据形状时直接用(见 `SKILL.md` 第零节 3.1 / 3.2)。
|
||||
> **选型优先级以 `SKILL.md` 的硬约束为准:默认先完整审计 Lupi Editorial,再审计 Lupi Basics;只有两组都不适配,或用户明确要求 Glance / dashboard / 三秒快读时,才进入 Glance。**
|
||||
> 「姊妹」列 = 同题异构对子,只用于比较数据契约和召回候选,不代表两案都要生成,也不能改变上述优先级。
|
||||
> 参考实现在 `templates/`:Glance 系 `templates/glance-gallery.html`,Lupi 系 `templates/lupi-gallery.html`,基础型组 `templates/basics-gallery.html`,地图 `templates/maps-gallery.html`,大图 `templates/big-*.html`。地图只有在用户明确要求地图或地域分布时才召回。gallery 是多卡合页——查某张图的代码,先按下表「卡内标题」找到卡片,再在 `<script>` 里搜同名 `// ════` 注释块。真实数据成品案例在 `examples/`。
|
||||
|
||||
## Glance 系 · 22 张(粗笔画 · 提前聚合 · 3 秒读完)
|
||||
|
||||
| # | 名字 | 卡内标题 | 数据形状 | 场合 | 读者时间 | 引擎 | 姊妹 |
|
||||
|---|------|---------|---------|------|---------|------|------|
|
||||
| G1 | Range Capsules | Daily active range | 每天一个区间(min–max)的日序列 | 周报 dashboard | <10s | Chart.js* | L3 Barcode Lollipop |
|
||||
| G2 | Petal Rose | How releases made us feel | 单变量分类计数,≤8 类且近似等分 | 门面/封面 | <10s | ECharts(暗卡) | G13 Big Slice(双变量时升级) |
|
||||
| G3 | Chunky Bars | Revenue by plan | 少类目排名比较(≤6) | 周报 dashboard | <10s | Chart.js* | L2 Dot Cascade;多选百分比 → L15 Ballot Tally |
|
||||
| G4 | Dot Waffle | Where sign-ups come from | 100% 构成(占比) | 通用,饼图的默认替代 | <10s | 手写 SVG | L14 Hundred Field |
|
||||
| G5 | Pictorial Bar | Trees planted, year by year | 逐年计数(一个符号=固定数量) | 对外故事页 | <10s | ECharts | — |
|
||||
| G6 | Circular Graph(小) | Who works with whom | 网络,≤12 节点 | 快速示意 | <10s | ECharts(暗卡) | 大图 B1 的预览版,节点多用 B1 |
|
||||
| G7 | Tree LR | Everything the platform ships | 层级结构(2–3 层) | 产品目录/架构页 | ~30s | ECharts | — |
|
||||
| G8 | Rainfall Dual Area | Campaigns rain down | 双序列因果(投入 vs 产出) | 增长复盘 | ~30s | ECharts | — |
|
||||
| G9 | Scatter Morph | One dataset, three views | 同一实体集的三个维度轮播 | 短视频/演示 | 动画 | ECharts universalTransition | — |
|
||||
| G10 | Diverging Bar | Where we gained, where we bled | 有正负的分类数值 | 周报 dashboard | <10s | ECharts | —(Equalizer、Meridian Dots 已删) |
|
||||
| G11 | Force Graph(小) | Integrations, pulled into orbit | 中心+卫星网络,≤15 节点 | 快速示意,可拖 | <10s | ECharts(暗卡) | 大图 B2 的预览版;静态海报版 L6 |
|
||||
| G12 | Stagger Wave | Fifty markets, one wave | 多类目分布(30–60 根) | 短视频入场 | 动画 | ECharts | — |
|
||||
| G13 | Big Slice (Custom Pie) | Big slice, deep engagement | 双编码:占比(角)×强度(径) | 产品分析 | ~30s | ECharts custom | G2 Petal Rose(单变量时降级) |
|
||||
| G14 | Single Axis | Support load, day by day | 星期×小时×量(punch card 数据) | 支持/运维周报 | ~30s | ECharts | —(Punch Card 已删,此为唯一版本) |
|
||||
| G15 | Jitter Strip | Response times, spread out | 分组分布,逐条记录(几百点) | SRE/工单分析 | ~30s | ECharts | — |
|
||||
| G16 | Bar Race | Eight products race | 排名随时间演变 | 短视频 | 动画 | ECharts realtimeSort | — |
|
||||
| G17 | Dynamic Stream | Concurrent users, streaming | 实时滚动序列 | 直播/大屏 | 动画 | ECharts | — |
|
||||
| G18 | Draw-in + Counter | H1 revenue, drawn in one stroke | 累计增长(一条线+一个大数) | 短视频/汇报开场 | 动画 | ECharts | —(Release Rings 已删) |
|
||||
| G19 | Violin | How fast each plan gets an answer | 分组连续分布的密度轮廓 + 中位数 | dashboard/分析汇报 | <10s | SVG | F15 Tick Box;L19 Ridgeline |
|
||||
| G20 | Matrix Heat (Glance) | Adoption runs hot on the new versions | 两个离散维度 × 数值,≤60 格,每格直接读数 | dashboard/产品分析 | <10s | SVG | L16 Matrix Heat(细读版) |
|
||||
| G21 | Rank Strip | Flows climbs to the top | 多实体排名随离散时间变化,适合静态印刷 | 汇报/账单复盘 | <10s | SVG | G16 Bar Race(动态演示) |
|
||||
| G22 | Aggregate Sankey | Channels pour into plans | 两端聚合流量,带宽=数量,不要求逐路径查询 | 归因/转化分析 | ~30s | SVG | B3 Threads(逐路径查询) |
|
||||
|
||||
\* G1/G3 仍使用 Chart.js;后续可迁移到 ECharts,以统一渲染栈。
|
||||
|
||||
## Lupi 系 · 20 张(发丝线 · 逐记录 · 30 秒阅读)
|
||||
|
||||
| # | 名字 | 卡内标题 | 数据形状 | 场合 | 读者时间 | 引擎 | 姊妹 |
|
||||
|---|------|---------|---------|------|---------|------|------|
|
||||
| L1 | Launch Fan | Twelve features, fanned out | 多实体各带出生时间+当前规模 | 年报/故事页 | ~30s | SVG | — |
|
||||
| L2 | Dot Cascade | What breaks, stacked and ranked | 排名比较,可数单位(unit chart) | 年报/故事页 | ~30s | SVG(暗卡) | G3 Chunky Bars |
|
||||
| L3 | Barcode Lollipop | Ninety days as a barcode | 每天一个读数的日序列(90 天级) | 年报/故事页(配文字栏) | ~30s | SVG(通栏) | G1 Range Capsules |
|
||||
| L4 | Arc Matrix | Eight products land in twelve cities | 分类×分类+量,小数据(≤100 格) | 轻量矩阵 | ~30s | SVG | L9 Almanac 的轻量版,数据薄用这张 |
|
||||
| L5 | Radial Convergence | 48 requests pull toward five themes | 多对一归属,不丢明细(≤60 条) | 海报/封面 | ~30s | SVG | L12 Colonnade(两案供选) |
|
||||
| L6 | Cluster Field | The contributor field | 中心+卫星网络,海报版 | 海报/封面(认形不查数) | ~30s | SVG(通栏) | B2 force-big(要交互/查数用 B2) |
|
||||
| L7 | Brand Spectrum | Where the brand sits | 双极量表(两端都是合法位置)+ 竞品对照 | 品牌调研报告 | ~30s | SVG | —(Equalizer 已删,唯一保留的量表图) |
|
||||
| L8 | Dotty Matrix | Four squads, stacked in space | 多组×网格×量,等距堆叠 | 封面/海报(装饰强) | ~30s | SVG | G14 摊平版更易读,要读数用 G14 |
|
||||
| L9 | Bubble Almanac | Eight years of tickets, one almanac | 分类×年份+量+状态,大跨度(手绘 blob) | 年报(带旁注/大事记) | >30s | SVG(通栏) | L4 Arc Matrix(轻量版) |
|
||||
| L10 | Radial Patchwork | A quarter of deploys, overlaid | 逐事件叠加:时刻(角)×规模(径),透明度=密度 | 年报/故事页 | >30s | SVG | —(Polar Line 已删,此为唯一 24h 分布图) |
|
||||
| L11 | Trend Lineage | Features rise, fall, come back | 事件序列生命史(首发/重做/休眠/存活) | 产品复盘 | >30s | SVG | — |
|
||||
| L12 | Type Colonnade | Forty-four repos, ten owners | 多对一归属+逐条名单(≤50 条) | 治理/审计报告 | ~30s | SVG | L5 Convergence(两案供选) |
|
||||
| L13 | Hourglass Stream | The funnel, poured | 分阶段递减人数(漏斗) | 年报/故事页 | ~30s | SVG | — |
|
||||
| L14 | Hundred Field | A hundred of us, four minds | 100% 构成(占比),≤6 类小数据 | 年报/故事页 | ~30s | SVG | G4 Dot Waffle |
|
||||
| L15 | Ballot Tally | What they fear, tick by tick | 多选题百分比(各项独立 0–100),≤6 项 | 年报/故事页 | ~30s | SVG | G3 Chunky Bars |
|
||||
| L16 | Matrix Heat | Which features get used together | 两个离散维度 × 数值,≤100 格,保留矩阵结构与重点格 | 年报/产品分析 | ~30s | SVG | G20 Matrix Heat(快读版) |
|
||||
| L17 | Calendar Heat | A year of deploys, day by day | 一整年日期 × 数量,52 周 × 7 天 | 年报/运营复盘 | ~30s | SVG(通栏) | F10 Dot Heat(星期×小时) |
|
||||
| L18 | Beeswarm | A hundred and twenty deals, swarming | 单变量逐条记录的堆积分布,约 40–180 点 | 销售/研究附图 | ~30s | SVG | G15 Jitter Strip;G19 Violin |
|
||||
| L19 | Ridgeline | Five pipelines, five tempos | 3–8 组连续分布的密度形状比较 | 年报/研究报告 | >30s | SVG | G19 Violin(少组快读) |
|
||||
| L20 | Parallel Coordinates | Twelve products, four dimensions | 同一实体集跨 3–6 个连续维度,一线一实体 | 产品组合/研究报告 | >30s | SVG | G9 Scatter Morph(演示轮播) |
|
||||
|
||||
> L14–L15 是**小数据组**:数据只有几个百分比时靠单位分解拿回 Lupi 密度——1 点 = 1 人 / 1 百分点,密度来自单位而不是记录数。单位含义必须写进副标题(如 "one dot = one person in a hundred"),且只摊诚实单位,不编造个体。
|
||||
|
||||
## 基础型组 · 17 张(F1–F17 · Lupi 语法 × 基础图型剪影,为稀疏数据而设)
|
||||
|
||||
远看认得出基础图型(柱/折线/环形…),近看每个单位可数。数据只有几个类目或几十天时的 Lupi 首选——先来这里找,找不到再走库外翻译。参考实现 `templates/basics-gallery.html`。
|
||||
|
||||
| # | 名字 | 卡内标题 | 数据形状 | 场合 | 读者时间 | 引擎 | 姊妹 |
|
||||
|---|------|---------|---------|------|---------|------|------|
|
||||
| F1 | Rung Bars | Revenue by plan, rung by rung | 少类目比较(≤8),单位可数 | 年报/故事页 | ~30s | SVG | G3 Chunky Bars |
|
||||
| F2 | Hairline Line | Thirty days of sign-ups | 日序列(≤30 天,逐日读数) | 年报/故事页 | ~30s | SVG | G1 Range Capsules |
|
||||
| F3 | Hairline Area | Concurrent users, filled with days | 日序列(30–60 天,看形态) | 年报/故事页 | ~30s | SVG | L3 Barcode Lollipop |
|
||||
| F4 | Tick Donut | Where the traffic comes from | 100% 构成(≤6 段) | 年报/故事页 | ~30s | SVG | G4 Dot Waffle ⇄ L14 Hundred Field |
|
||||
| F5 | Tick Rows | Six teams, shipped and counted | 横向排名比较,单位可数(≤8 行) | 年报/故事页 | ~30s | SVG | L2 Dot Cascade |
|
||||
| F6 | Paired Rungs | This year against last, plan by plan | 分组对比(每类 2 系列,如今昔) | 年报/复盘 | ~30s | SVG | — |
|
||||
| F7 | Stacked Rungs | Where each region's revenue sits | 堆叠构成(≤4 类 × ≤3 段) | 年报/复盘 | ~30s | SVG | — |
|
||||
| F8 | Plumb Scatter | Price against satisfaction | 二维散点(≤20 点) | 产品分析 | ~30s | SVG | G15 Jitter Strip(分布用) |
|
||||
| F9 | Rung Waterfall | From gross to net, step by step | 瀑布 / 增减分解(≤6 级) | 财务/复盘 | ~30s | SVG | — |
|
||||
| F10 | Dot Heat | When support gets loud | 星期×小时×量(小热力) | 支持/运维 | ~30s | SVG | G14 Single Axis |
|
||||
| F11 | Tick Gauge | How far to the quarter's goal | 单值进度(0–100%) | 汇报开场 | <10s | SVG | G18 Draw-in + Counter |
|
||||
| F12 | Dumbbell Queue | Onboarding, before and after | 类目级前后对比(≤6 类,串珠=真单位) | 年报/复盘 | ~30s | SVG | —(两点趋势的 Lupi 位,替代已删 Slope Beads) |
|
||||
| F13 | Nested Treemap | Where the work went | 两层层级 + 正数权重,矩形面积=数值 | 预算/产品组合/空间占用 | ~30s | ECharts(SVG) | G7 Tree LR(只看从属关系、不看份额时) |
|
||||
| F14 | Rung Histogram | Most tickets resolve within six hours | 单变量分箱频数,bin 有业务含义且单位可数 | 支持/运营分析 | ~30s | SVG | G19 Violin;F15 Tick Box |
|
||||
| F15 | Tick Box | Reply times, boxed by plan | 分组五数概括 + 异常值,原始分布可被汇总 | 支持/实验分析 | ~30s | SVG | G19 Violin;F14 Histogram |
|
||||
| F16 | Stream Ribbon | Three products trade the same river | 2–5 个系列的构成随连续时间变化,同时看总量 | 产品/流量复盘 | ~30s | SVG(通栏) | F7 Stacked Rungs(静态类目) |
|
||||
| F17 | Candlestick | Six weeks of the token, candle by candle | OHLC 四值时间序列,空心=涨、实心=跌 | 行情/价格复盘 | ~30s | SVG | G1 Range Capsules(只有区间时) |
|
||||
|
||||
## 地图 · 2 张(仅在用户明确要求地图时召回)
|
||||
|
||||
数据里出现国家、州省或地区字段,**不等于自动使用地图**。只有用户明确提出“地图”“地域分布”“按国家/州着色”等要求时,才从 `templates/maps-gallery.html` 选择。地图通过 ECharts 与在线 GeoJSON 渲染,需要联网。
|
||||
|
||||
| # | 名字 | 卡内标题 | 数据形状 | 场合 | 读者时间 | 引擎 | 限制 |
|
||||
|---|------|---------|---------|------|---------|------|------|
|
||||
| M1 | US Choropleth | Sign-ups across the states | 美国州级区域 + 非负数值,明度=数值 | 美国市场/运营报告 | ~30s | ECharts + GeoJSON | 仅用户明确要求;州面积不表示数值 |
|
||||
| M2 | World Choropleth | Where the users are | 世界国家级区域 + 非负数值,明度=数值 | 全球市场/运营报告 | ~30s | ECharts + GeoJSON | 仅用户明确要求;不用于点位或路径 |
|
||||
|
||||
## 独立交互大图 · 3 张(一图一文件,整页幅面)
|
||||
|
||||
| # | 文件 | 数据形状 | 交互 | 什么时候用 |
|
||||
|---|------|---------|------|-----------|
|
||||
| B1 | `templates/big-circular.html` | 网络 60 节点环形弦膜 | hover 聚焦邻接,点击重播 | 节点 >15 的关系数据 |
|
||||
| B2 | `templates/big-force.html` | 网络 180 节点力导向星系 | 拖拽回弹、hover 聚焦 | 大网络要逐节点查 |
|
||||
| B3 | `templates/big-threads.html` | 三段路径 100+ 条丝线 | hover 单线/整束,点击钉住,状态栏读数 | 多段流向要逐路径查 |
|
||||
|
||||
## 已移除图型(已有更合适的替代)
|
||||
|
||||
Polar Line(24h 分布 → L10)、Punch Card(→ G14)、Release Rings(风格不属于任何一系)、静态 Thread Triptych(→ B3)、Profile Equalizer(→ G10)、Slope Beads(交叉斜线的可读性不足,斜率图仍待重新设计)、Meridian Dots(正负分类改用 G10)。后两张源自未采用的 Lenny 案例草稿,不建议按原样恢复。
|
||||
@@ -0,0 +1,179 @@
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
COLOR PRESETS — 三套内置彩色预设的唯一正本
|
||||
Mono 是保底;数据语义和使用场景明确适配时,可自动选择
|
||||
这里的一套彩色起点。同一交付不得混用多套预设。
|
||||
与 mono-tokens.js 的关系:本文件只覆盖颜色,字体 / 圆角 /
|
||||
动画参数 / 卡片结构一律仍以 mono-tokens.js 为准。
|
||||
任何内置彩色 gallery 里出现与本文件冲突的色值,以本文件为准。
|
||||
用户明确提供的 custom 色板只内联在对应交付中,不写入本文件,
|
||||
也不得借用这里的色值补齐 custom。
|
||||
用法:挂在 window.PRESETS 上,取一套展开进图表代码;
|
||||
开源分发时把选中的那一套内联进单文件 HTML。
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
/* ── 彩色版的两条通用加墨规则 ───────────────────────────────
|
||||
灰阶下 0.5px 发丝线看得见,换成彩色淡色就消失了。
|
||||
这不是可选项,三套都要遵守。 */
|
||||
const INK_BOOST = {
|
||||
strokeScale: 1.8, // 线宽 ×1.8
|
||||
opacityFloor: 0.85, // 透明度地板(dot heat 除外,它的墨本来就够)
|
||||
exempt: ['dot heat'],
|
||||
};
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
PORCELAIN — 单色相蓝阶
|
||||
色彩逻辑:明度即数据,完整继承 Mono 的契约,只是把灰换成蓝。
|
||||
适配:有序数据(进度 / 热力 / 时间序列 / 单指标 / 排名)。
|
||||
容量:4 档明度。类目 >4 换 Mono 灰阶。
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
const PORCELAIN = {
|
||||
name: 'porcelain',
|
||||
cn: '青瓷蓝',
|
||||
logic: 'ordinal', // 明度=数值,色相恒定
|
||||
BG: '#F7F2EB',
|
||||
TXT: '#081F5C',
|
||||
MUT: 'rgba(8,31,92,.60)',
|
||||
LAB: 'rgba(8,31,92,.72)',
|
||||
FAINT: 'rgba(8,31,92,.32)',
|
||||
FLOOR: 'rgba(8,31,92,.24)',
|
||||
QUIET: 'rgba(8,31,92,.15)',
|
||||
TRACK: 'rgba(8,31,92,.12)',
|
||||
GRID: 'rgba(8,31,92,.16)',
|
||||
DATA: '#334EAC',
|
||||
DATA2: '#7096D1',
|
||||
HERO: '#081F5C',
|
||||
HERODK: '#7096D1',
|
||||
FAINTDATA: '#BAD6EB',
|
||||
BEAD: '#7096D1',
|
||||
HALO: 'rgba(247,242,235,.92)',
|
||||
CAT4: ['#081F5C', '#334EAC', '#7096D1', '#BAD6EB'],
|
||||
CAT3: ['#334EAC', '#7096D1', '#BAD6EB'],
|
||||
HEAT: ['#334EAC', '#7096D1', '#BAD6EB'],
|
||||
SER: ['#334EAC', '#081F5C', '#7096D1', '#BAD6EB', '#D0E3FF', 'rgba(51,78,172,.4)'],
|
||||
RAMP: ['#D0E3FF', '#BAD6EB', '#7096D1', '#334EAC', '#081F5C'],
|
||||
DRAMP4: ['#D0E3FF', '#BAD6EB', '#7096D1', 'rgba(247,242,235,.26)'],
|
||||
RAMPDK: ['rgba(247,242,235,.26)', 'rgba(247,242,235,.42)',
|
||||
'rgba(247,242,235,.60)', 'rgba(247,242,235,.84)', '#7096D1'],
|
||||
// ── 暗底大图专用(big-circular / big-force / big-threads)──
|
||||
// 卡底压深,8 档单色相阶从亮到暗,编码"离核心多远"(亮 = 核心)
|
||||
DARK: {
|
||||
CARDBG: '#081F5C',
|
||||
DK8: ['#EDEFF1','#D5DBE2','#BCC7D7','#9EB3CD','#809EC6','#6C93C7','#4D82C6','#3472C2'],
|
||||
HL: '#EDEFF1', // hover 高亮
|
||||
LABEL: '#9EB3CD', SUB: '#809EC6', SRCROW: '#3E5A8F', RULE: '#1B3159',
|
||||
LINK_SAME: '#32517B', LINK_CROSS: '#263E5E',
|
||||
LINK_HEAVY: '#3B6091', LINK_FAINT: '#213650',
|
||||
},
|
||||
};
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
PALM — 绿黄低饱和 + 琥珀强调
|
||||
色彩逻辑:色相=类目(无序),琥珀单独承担"这个是重点"。
|
||||
适配:几个互不排序的类目并列(来源 / 团队 / 产品线 / 地区)。
|
||||
容量:4 个类目干净,6 个勉强。>6 换 porcelain 或 Mono。
|
||||
注意:琥珀 160 和橄榄 167 明度只差 7,靠饱和度差 56 分开。
|
||||
两者语义相反(琥珀跳出、橄榄退后),实际读不混,
|
||||
但别把它们当两个平等类目用。
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
const PALM = {
|
||||
name: 'palm',
|
||||
cn: '椰林绿',
|
||||
logic: 'categorical', // 色相=类目
|
||||
BG: '#F0EFEB',
|
||||
TXT: '#58402E',
|
||||
MUT: 'rgba(88,64,46,.60)',
|
||||
LAB: 'rgba(88,64,46,.72)',
|
||||
FAINT: 'rgba(88,64,46,.32)',
|
||||
FLOOR: 'rgba(88,64,46,.24)',
|
||||
QUIET: 'rgba(88,64,46,.15)',
|
||||
TRACK: 'rgba(88,64,46,.12)',
|
||||
GRID: 'rgba(88,64,46,.16)',
|
||||
DATA: '#43593B', // 深绿,明度 79
|
||||
DATA2: '#58402E', // 浓咖,明度 69(也是文字色)
|
||||
HERO: '#D4A017', // 琥珀,明度 160,S80 —— 在一堆 S20 灰绿里跳
|
||||
HERODK: '#F2D17E', // 暗卡强调:深绿在浓咖底上读不出,改麦黄
|
||||
FAINTDATA: '#ACAD79',
|
||||
BEAD: '#77835A',
|
||||
HALO: 'rgba(240,239,235,.92)',
|
||||
CAT4: ['#43593B', '#77835A', '#ACAD79', '#F2D17E'], // 79/123/167/209
|
||||
CAT3: ['#43593B', '#929960', '#F2D17E'], // 79/144/209
|
||||
CAT3L: ['#43593B', '#77835A', '#ACAD79'],
|
||||
HEAT: ['#43593B', '#929960', '#F2D17E'],
|
||||
SER: ['#43593B', '#D4A017', '#77835A', '#F2D17E', '#ACAD79', '#58402E'],
|
||||
// 序数梯:纯绿→黄六档,明度 79→209 均匀(间距 22/22/21/23/42)
|
||||
RAMP6: ['#43593B', '#5A7049', '#77835A', '#929960', '#ACAD79', '#F2D17E'],
|
||||
RAMP: ['#F2D17E', '#ACAD79', '#929960', '#77835A', '#43593B'],
|
||||
SEG3: ['#43593B', '#77835A', '#ACAD79'], // 分段:月份等图内既有维度
|
||||
ZONE3: ['#ACAD79', '#929960', '#43593B'], // 区段:越接近目标越深
|
||||
RAMPDK: ['rgba(240,239,235,.26)', 'rgba(240,239,235,.42)',
|
||||
'rgba(240,239,235,.60)', 'rgba(240,239,235,.84)', '#F2D17E'],
|
||||
DRAMP4: ['#F2D17E', '#ACAD79', 'rgba(240,239,235,.55)', 'rgba(240,239,235,.26)'],
|
||||
// ── 暗底大图专用 ──
|
||||
// 卡底用深绿压暗(浓咖 69 太亮压不住),8 档麦黄→深绿
|
||||
DARK: {
|
||||
CARDBG: '#28311F',
|
||||
DK8: ['#F0EFE9','#DDDBC9','#C8CAA8','#AFB885','#92A763','#7EA056','#658D49','#4F7D3E'],
|
||||
// hover 高亮走麦黄。例外:big-threads 的高亮机制是改 opacity
|
||||
// (线本身恒为 DK8[0]),那张图不使用 HL。
|
||||
HL: '#F2D17E',
|
||||
LABEL: '#ACAD79', SUB: '#92A763', SRCROW: '#5A6B47', RULE: '#38452C',
|
||||
LINK_SAME: '#405333', LINK_CROSS: '#334329',
|
||||
LINK_HEAVY: '#4C6340', LINK_FAINT: '#2C3823',
|
||||
},
|
||||
};
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
WIRE — 黑灰阶 + 一点荧光橙
|
||||
色彩逻辑:灰阶承载全部数据(同 Mono),橙只标一个主角元素
|
||||
(最大柱 / 峰值 / NET / 改版后)。
|
||||
适配:几乎所有场合。想要 Mono 的克制但需要一个视线落点时用它。
|
||||
容量:灰阶 7 档,和 Mono 一样。橙永远只给一个元素——
|
||||
第二处上橙就等于没有主角。
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
const WIRE = {
|
||||
name: 'wire',
|
||||
cn: '编辑部红',
|
||||
logic: 'mono+accent', // 灰阶=数据,橙=唯一主角
|
||||
BG: '#F0F0EE',
|
||||
TXT: '#1F1E1C',
|
||||
MUT: 'rgba(31,30,28,.60)',
|
||||
LAB: 'rgba(31,30,28,.72)',
|
||||
FAINT: 'rgba(31,30,28,.32)',
|
||||
FLOOR: 'rgba(31,30,28,.24)',
|
||||
QUIET: 'rgba(31,30,28,.15)',
|
||||
TRACK: 'rgba(31,30,28,.12)',
|
||||
GRID: 'rgba(31,30,28,.16)',
|
||||
DATA: '#22211F',
|
||||
DATA2: '#8F8E86',
|
||||
HERO: '#F5572F', // 荧光橙 —— 每张图只给一个元素
|
||||
HERODK: '#F5572F',
|
||||
FAINTDATA: '#C0BFB7',
|
||||
BEAD: '#8F8E86',
|
||||
HALO: 'rgba(255,255,255,.92)',
|
||||
CAT4: ['#F5572F', '#22211F', '#8F8E86', '#C0BFB7'],
|
||||
CAT3: ['#F5572F', '#22211F', '#8F8E86'],
|
||||
CAT3L: ['#F5572F', '#22211F', '#6E6D66'],
|
||||
HEAT: ['#F5572F', '#6E6D66', '#C0BFB7'],
|
||||
SER: ['#22211F', '#8F8E86', '#C0BFB7', 'rgba(34,33,31,.52)', '#DBDAD3', 'rgba(34,33,31,.3)'],
|
||||
RAMP: ['#DBDAD3', '#C0BFB7', '#8F8E86', '#22211F', '#F5572F'],
|
||||
DRAMP4: ['#F5572F', '#C0BFB7', '#8F8E86', 'rgba(240,240,238,.26)'],
|
||||
RAMPDK: ['rgba(240,240,238,.26)', 'rgba(240,240,238,.42)',
|
||||
'rgba(240,240,238,.60)', 'rgba(240,240,238,.84)', '#F5572F'],
|
||||
};
|
||||
|
||||
/* ── 人话映射:用户不会说色系名,会说"蓝一点""暖一点" ── */
|
||||
const BY_WORDS = {
|
||||
'蓝': 'porcelain', '冷': 'porcelain', '学术': 'porcelain',
|
||||
'理性': 'porcelain', '严肃': 'porcelain', '科技': 'porcelain',
|
||||
'绿': 'palm', '暖': 'palm', '自然': 'palm', '温柔': 'palm',
|
||||
'复古': 'palm', '手工': 'palm', '莫兰迪': 'palm',
|
||||
'黑白': 'wire', '克制': 'wire', '杂志': 'wire', '编辑部': 'wire',
|
||||
'简': 'wire', '不要彩色但要一个重点': 'wire',
|
||||
};
|
||||
|
||||
global.PRESETS = { PORCELAIN, PALM, WIRE, INK_BOOST, BY_WORDS,
|
||||
list: ['porcelain', 'palm', 'wire'],
|
||||
get: n => ({ porcelain: PORCELAIN, palm: PALM, wire: WIRE })[String(n).toLowerCase()] || null };
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
|
After Width: | Height: | Size: 403 KiB |
|
After Width: | Height: | Size: 455 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1014 KiB |
|
After Width: | Height: | Size: 607 KiB |
|
After Width: | Height: | Size: 598 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 572 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 347 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 329 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 350 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 504 KiB |
|
After Width: | Height: | Size: 313 KiB |
|
After Width: | Height: | Size: 297 KiB |
|
After Width: | Height: | Size: 407 KiB |
|
After Width: | Height: | Size: 341 KiB |
|
After Width: | Height: | Size: 727 KiB |
|
After Width: | Height: | Size: 461 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 457 KiB |
|
After Width: | Height: | Size: 507 KiB |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 464 KiB |
@@ -0,0 +1,35 @@
|
||||
# Examples · 真实数据成品案例
|
||||
|
||||
templates/ 里的 gallery 是「例句」(演示数据、多卡合页);这里是「成文」——拿一篇真实文章的公开数据,从判形状到出图走完整个 skill 流程的成品。拿不准交付物该长什么样,先看这里。
|
||||
|
||||
## r04-financial-report.zh.html
|
||||
|
||||
这是 R04「月度运营」模板的一份财务场景示例:保留原有版心和四个图表槽位,将内容改为月度经营财报,包括收入、毛利、经营现金流、毛利率、现金可支撑月数、每日收入、回款时段、收入构成和业务线毛利贡献。数据为虚构演示数据,用来说明同一模板如何适配金融 / 经济类报告。
|
||||
|
||||
预览图:[R04 月度经营财报](../docs/assets/examples/r04-financial-report.zh.png)
|
||||
|
||||
## lenny-2026-survey.html
|
||||
|
||||
数据来源:Lenny's Newsletter《How tech workers are feeling in 2026》(2026-07-07,年度科技从业者调研)。一篇文章 → 8 张图,全英文,8 个模板不重复:
|
||||
|
||||
这是图数规则确定前保留的早期展示案例,用 8 张图覆盖更多模板。当前 skill 处理同类文章时,默认会筛选为 4–6 张,并在超过 6 张时拆页。
|
||||
|
||||
| # | 模板 | 数据 |
|
||||
|---|------|------|
|
||||
| 1 | Type Colonnade (L12) | AI 身份认同 49/27/14/5/3 |
|
||||
| 2 | Ballot Tally (L15) | 矛盾三联:享受工作 79 · 显著倦怠 56 · 乐观 49 |
|
||||
| 3 | Hundred Faces(语义单位·通栏) | 裁员担忧 28/31/21/12/8,嘴角弧度=担忧程度 |
|
||||
| 4 | Tick Rows (F5) | 四大恐惧 51/46/41/22 |
|
||||
| 5 | Hourglass Stream (L13) | 矛盾收窄 100 → 77 → 51 |
|
||||
| 6 | Brand Spectrum (L7) | 分层 NPS −49/−23/−5 |
|
||||
| 7 | Radial Convergence (L5) | 行业一句话:混乱 30/快 17/泡沫 12/兴奋 11/其他 30 |
|
||||
| 8 | Ballot Rings(表盘 tick·通栏) | AI 生产力 82 → 49 |
|
||||
|
||||
这个文件顺便示范了几条容易忘的规矩:
|
||||
|
||||
- **取整要认账**:身份认同取整后只有 98,底注写「rounding ate the other two」,不凑假线。
|
||||
- **没有的数据不编**:Brand Spectrum 模板原有竞品对照点,Lenny 没这个数据,删空。
|
||||
- **精确份额关掉随机泄漏**:Radial Convergence 模板的 rnd 泄漏逻辑(约 8% 汇错 hub 制造有机感)在调研数据上要关。
|
||||
- **同一批多图模板不重复**:8 张图 8 个模板。
|
||||
- **cascade 竖排类目名只配短标签**——本例曾用 Dot Cascade 画恐惧数据,两轮都被评「看不清」,换成横排 Tick Rows 后解决:标签保持水平永远是更稳的选择。
|
||||
- **reduced-motion 降级不能漏**(无头截图验证时可用 `--force-prefers-reduced-motion` 绕过动画时序)。
|
||||
@@ -0,0 +1,445 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>How Tech Workers Are Feeling in 2026 · 8 charts, Mono</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--bg:#F0EFEB;--dark:#1C1C1A;--ink:#1C1C1A;--muted:#8F8E88;--faint:#C6C5BF;--grid:#DEDDD6}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);font-family:'Inter',sans-serif;color:var(--ink);padding:40px;-webkit-font-smoothing:antialiased}
|
||||
.pagehead{max-width:1100px;margin:0 auto 26px;padding:0 4px}
|
||||
.pagehead h1{font-size:22px;letter-spacing:-.02em;font-weight:800}
|
||||
.pagehead p{font-size:12px;color:#8F8E88;margin-top:5px;line-height:1.7}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:22px;max-width:1100px;margin:0 auto}
|
||||
.card{background:var(--bg);border-radius:24px;padding:28px 28px 20px}
|
||||
.card.dark{background:var(--dark);color:#F0EFEB}
|
||||
.card.dark .sub{color:#8F8E88}
|
||||
.card.dark .src{color:#55554F}
|
||||
.card.wide{grid-column:1/-1}
|
||||
h2{font-weight:700;font-size:16.5px;letter-spacing:-.02em;margin-bottom:3px}
|
||||
.sub{font-size:11.5px;color:var(--muted);margin-bottom:14px}
|
||||
.src{font-size:9.5px;color:var(--faint);margin-top:10px;letter-spacing:.08em;font-weight:500}
|
||||
svg{width:100%;max-height:330px;display:block;margin:0 auto}
|
||||
svg text{font-family:'Inter',sans-serif}
|
||||
.pop{transform-box:fill-box;transform-origin:center;animation:pop .5s cubic-bezier(.2,.7,.3,1.3) both}
|
||||
@keyframes pop{from{transform:scale(0)}to{transform:none}}
|
||||
.fade{animation:fade .9s ease both}
|
||||
@keyframes fade{from{opacity:0}}
|
||||
.draw{stroke-dasharray:1;stroke-dashoffset:1;animation:draw 1s cubic-bezier(.4,0,.2,1) both}
|
||||
@keyframes draw{to{stroke-dashoffset:0}}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.pop,.fade{animation:none}
|
||||
.draw{animation:none;stroke-dasharray:none;stroke-dashoffset:0}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="pagehead">
|
||||
<h1>How tech workers are feeling in 2026 · eight charts</h1>
|
||||
<p>Data: Lenny's Newsletter annual tech worker sentiment survey (Jul 2026). Every chart is a gallery template with only its DATA swapped — one unit is always one real person or one real answer. Scroll to draw; click any chart to replay.</p>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
|
||||
<!-- 1 · type colonnade ← AI identity -->
|
||||
<div class="card">
|
||||
<h2>What AI did to a hundred identities</h2>
|
||||
<div class="sub">one hairline = one person in a hundred · each thread joins the identity they chose · rounding ate 2 people</div>
|
||||
<svg id="colonnade" viewBox="0 0 400 330" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">TYPE COLONNADE · AI IDENTITY, 5 NUMBERS</div>
|
||||
</div>
|
||||
|
||||
<!-- 2 · ballot tally ← the contradiction -->
|
||||
<div class="card">
|
||||
<h2>Love the work, worn out, losing faith</h2>
|
||||
<div class="sub">one tick = one person in a hundred · same respondents, three different questions</div>
|
||||
<svg id="tally" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">BALLOT TALLY · ENJOYMENT / BURNOUT / OPTIMISM, 3 NUMBERS</div>
|
||||
</div>
|
||||
|
||||
<!-- 3 · hundred faces ← layoff worry, wide -->
|
||||
<div class="card wide">
|
||||
<h2>How worried is the room</h2>
|
||||
<div class="sub">one face = one person in a hundred · the mouth is the data · dark ink piles up where worry does</div>
|
||||
<svg id="faces" viewBox="0 0 720 340" preserveAspectRatio="xMidYMid meet" style="max-height:380px"></svg>
|
||||
<div class="src">HUNDRED FACES · LAYOFF WORRY, 5 NUMBERS</div>
|
||||
</div>
|
||||
|
||||
<!-- 4 · tick rows ← fears -->
|
||||
<div class="card">
|
||||
<h2>What they fear, tick by tick</h2>
|
||||
<div class="sub">one tick = one person in a hundred who picked it · they could pick several · sorted by dread</div>
|
||||
<svg id="fears" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">TICK ROWS · WORKPLACE FEARS, 4 NUMBERS</div>
|
||||
</div>
|
||||
|
||||
<!-- 5 · hourglass stream ← ambivalence narrowing -->
|
||||
<div class="card">
|
||||
<h2>Ambivalence, layer by layer</h2>
|
||||
<div class="sub">one tick = one person in a hundred · everyone, then both-feelings, then the openly conflicted</div>
|
||||
<svg id="hourglass" viewBox="0 0 400 330" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">HOURGLASS STREAM · FEELINGS ABOUT AI, 3 NUMBERS</div>
|
||||
</div>
|
||||
|
||||
<!-- 6 · brand spectrum ← NPS by seniority -->
|
||||
<div class="card">
|
||||
<h2>Who would still recommend it</h2>
|
||||
<div class="sub">each rank sits between NEVER and GLADLY · the pale ribbon slides left as you climb down</div>
|
||||
<svg id="spectrum" viewBox="0 0 460 300" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">BRAND SPECTRUM · NPS BY SENIORITY, 3 NUMBERS</div>
|
||||
</div>
|
||||
|
||||
<!-- 7 · radial convergence ← industry in one sentence -->
|
||||
<div class="card">
|
||||
<h2>A hundred answers, five ways to say it</h2>
|
||||
<div class="sub">every rim node = one answer · threads bundle into the theme it landed in · chaos pulls hardest</div>
|
||||
<svg id="converge" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">RADIAL CONVERGENCE · "THE INDUSTRY IN A SENTENCE", 5 THEMES</div>
|
||||
</div>
|
||||
|
||||
<!-- 8 · ballot rings ← AI helping at work, wide finale -->
|
||||
<div class="card wide">
|
||||
<h2>Is AI actually making them better?</h2>
|
||||
<div class="sub">one tick = one person in a hundred · inked = better at their job because of AI · the right dial lives inside the left</div>
|
||||
<svg id="rings" viewBox="0 0 720 300" preserveAspectRatio="xMidYMid meet" style="max-height:340px"></svg>
|
||||
<div class="src">BALLOT RINGS · AI PRODUCTIVITY, 2 NUMBERS</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
// ── shared Mono tokens ──
|
||||
const INK='#1C1C1A',PAPER='#F0EFEB',MUTED='#8F8E88',GRID='#DEDDD6';
|
||||
const NS='http://www.w3.org/2000/svg';
|
||||
const el=(p,t,a)=>{const n=document.createElementNS(NS,t);for(const k in a)n.setAttribute(k,a[k]);p.appendChild(n);return n};
|
||||
const txt=(p,a,s)=>{const n=el(p,'text',a);n.textContent=s;return n};
|
||||
const tip=(n,s)=>{const t=document.createElementNS(NS,'title');t.textContent=s;n.appendChild(t)};
|
||||
const rnd=(i,k)=>Math.abs(((i*73856093)^(k*19349663))%1000)/1000; // deterministic
|
||||
const D2R=Math.PI/180;
|
||||
const pol=(cx,cy,r,deg)=>[cx+r*Math.cos(deg*D2R),cy+r*Math.sin(deg*D2R)];
|
||||
const obsReveal=(id,fn)=>{
|
||||
const n=document.getElementById(id);
|
||||
const go=()=>{n.innerHTML='';fn(n)};
|
||||
const io=new IntersectionObserver(es=>{if(es[0].isIntersecting){go();io.disconnect()}},{threshold:.3});
|
||||
io.observe(n);
|
||||
n.style.cursor='pointer';
|
||||
n.addEventListener('click',go);
|
||||
};
|
||||
|
||||
// ════ 1 · type colonnade ════ AI identity: 49/27.4/13.9/5/3.2 → 49+27+14+5+3 = 98,
|
||||
// rounding ate 2 people — the footer says so instead of inventing threads.
|
||||
(()=>{
|
||||
const TEAM=[['AMPLIFIED','I can do more, and better',49],['REDEFINED','changing shape, not clearly good or bad',27],['DESTABILIZED','less sure what is still mine',14],['DIMINISHED','I feel less essential',5],['UNCHANGED','',3]];
|
||||
obsReveal('colonnade',s=>{
|
||||
const N=98;
|
||||
const teamY=j=>60+j*56,repoY=i=>14+i*2.9;
|
||||
const blocks=TEAM.flatMap(([name,,n],h)=>Array(n).fill(h));
|
||||
for(let i=0;i<N;i++){
|
||||
const yi=repoY(i),j=blocks[i],yj=teamY(j);
|
||||
el(s,'rect',{x:64,y:yi-1,width:4,height:2,fill:'#B0AFA9',
|
||||
class:'fade',style:`animation-delay:${i*.006}s`});
|
||||
if(i%10===0)txt(s,{x:58,y:yi+2.5,'font-size':5.5,fill:'#C6C5BF','text-anchor':'end',
|
||||
class:'fade',style:`animation-delay:${i*.006}s`},i+1);
|
||||
el(s,'path',{d:`M70 ${yi} C 160 ${yi} 190 ${yj} 262 ${yj}`,fill:'none',
|
||||
stroke:'#A8A7A0','stroke-width':.6,opacity:.6,pathLength:1,
|
||||
class:'draw',style:`animation-delay:${.2+i*.008}s;animation-duration:.7s`});
|
||||
}
|
||||
TEAM.forEach(([name,desc,n],j)=>{
|
||||
const y=teamY(j);
|
||||
const hub=el(s,'circle',{cx:267,cy:y,r:Math.sqrt(n)*1.7,fill:n>=27?INK:n>=14?'#6A6963':'#B0AFA9',
|
||||
class:'pop',style:`animation-delay:${.6+j*.05}s`});
|
||||
tip(hub,`${name} — ${n} in 100`);
|
||||
txt(s,{x:280+Math.sqrt(n)*1.2,y:y+1,'font-size':8,'font-weight':800,fill:'#1C1C1A',
|
||||
'letter-spacing':'.04em',class:'fade',style:`animation-delay:${.65+j*.05}s`},`${name} · ${n}`);
|
||||
if(desc)txt(s,{x:280+Math.sqrt(n)*1.2,y:y+11,'font-size':5.8,'font-weight':500,fill:'#8F8E88',
|
||||
class:'fade',style:`animation-delay:${.7+j*.05}s`},desc);
|
||||
});
|
||||
txt(s,{x:200,y:322,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade',style:'animation-delay:1.2s'},
|
||||
'ONE THREAD = ONE PERSON · 49+27+14+5+3 = 98 · ROUNDING ATE THE OTHER TWO');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ 2 · ballot tally ════ same respondents, three questions:
|
||||
// still enjoy the work 79 · significantly burned out 56 · optimistic 49.
|
||||
(()=>{
|
||||
const ASK=[['STILL ENJOY THE WORK (MODERATELY+)',79],['SIGNIFICANTLY BURNED OUT',56],['OPTIMISTIC ABOUT THEIR CAREER',49]];
|
||||
obsReveal('tally',s=>{
|
||||
ASK.forEach(([name,v],i)=>{
|
||||
const base=88+i*78;
|
||||
txt(s,{x:28,y:base-30,'font-size':7.5,'font-weight':700,fill:'#6A6963',
|
||||
'letter-spacing':'.06em',class:'fade',style:`animation-delay:${i*.1}s`},name);
|
||||
el(s,'line',{x1:28,y1:base,x2:372,y2:base,stroke:GRID,'stroke-width':.6,
|
||||
class:'fade',style:`animation-delay:${i*.1}s`});
|
||||
for(let k=0;k<100;k++){
|
||||
const x=28+k*3.44,picked=k<v;
|
||||
const h=picked?14+rnd(k+1,i+2)*6:5+rnd(k+1,i+5)*2.5;
|
||||
el(s,'line',{x1:x,y1:base,x2:x,y2:base-h,
|
||||
stroke:picked?INK:'#CFCEC7','stroke-width':picked?.9:.55,
|
||||
class:'fade',style:`animation-delay:${i*.1+k*.006}s`});
|
||||
if(k%10===0)el(s,'circle',{cx:x,cy:base+4.5,r:.8,fill:'#C6C5BF',
|
||||
class:'fade',style:`animation-delay:${i*.1+k*.006}s`});
|
||||
}
|
||||
const lab=txt(s,{x:28+(v-1)*3.44+9,y:base-13,'font-size':11.5,'font-weight':800,fill:INK,
|
||||
style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:${.5+i*.1}s`,
|
||||
class:'fade'},v);
|
||||
tip(lab,`${v} in 100`);
|
||||
});
|
||||
txt(s,{x:200,y:314,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade',style:'animation-delay:1.1s'},
|
||||
'ONE TICK = ONE PERSON · SAME RESPONDENTS · ALL THREE CAN BE TRUE AT ONCE');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ 3 · hundred faces ════ layoff worry 28/31/21/12/8 = 100. One face = one person;
|
||||
// mouth curve deepens with worry, grey ladder fills in step — ink piles up where worry does.
|
||||
(()=>{
|
||||
const SEG=[
|
||||
['NOT AT ALL',28,PAPER,INK,-2.6], // fill, feature color, mouth curve
|
||||
['SLIGHTLY',31,'#DEDDD6',INK,-.6],
|
||||
['MODERATELY',21,'#B0AFA9',INK,1.4],
|
||||
['VERY',12,'#55554F',PAPER,2.6],
|
||||
['EXTREMELY',8,INK,PAPER,3.6],
|
||||
];
|
||||
obsReveal('faces',s=>{
|
||||
const COLS=20,cell=26,R=8.6,X0=98,Y0=52;
|
||||
let idx=0;
|
||||
SEG.forEach(([name,n,fill,fg,curve],si)=>{
|
||||
for(let k=0;k<n;k++,idx++){
|
||||
const col=idx%COLS,row=Math.floor(idx/COLS);
|
||||
const x=X0+col*cell,y=Y0+row*cell;
|
||||
const g=el(s,'g',{class:'pop',style:`animation-delay:${idx*.008}s`});
|
||||
el(g,'circle',{cx:x,cy:y,r:R,fill,stroke:INK,'stroke-width':1});
|
||||
el(g,'circle',{cx:x-3,cy:y-2.4,r:1,fill:fg});
|
||||
el(g,'circle',{cx:x+3,cy:y-2.4,r:1,fill:fg});
|
||||
if(si===4){
|
||||
el(g,'line',{x1:x-4.4,y1:y-5.6,x2:x-1.8,y2:y-4.4,stroke:fg,'stroke-width':.9});
|
||||
el(g,'line',{x1:x+4.4,y1:y-5.6,x2:x+1.8,y2:y-4.4,stroke:fg,'stroke-width':.9});
|
||||
}
|
||||
const my=y+3.2;
|
||||
el(g,'path',{d:`M ${x-3.4} ${my} Q ${x} ${my+curve} ${x+3.4} ${my}`,
|
||||
fill:'none',stroke:fg,'stroke-width':1});
|
||||
tip(g,`${name} — one of ${n} in 100`);
|
||||
}
|
||||
});
|
||||
// top legend: shares with names
|
||||
let lx=98;
|
||||
SEG.forEach(([name,n],si)=>{
|
||||
txt(s,{x:lx,y:24,'font-size':8,'font-weight':800,fill:INK,
|
||||
class:'fade',style:`animation-delay:${.2+si*.06}s`},n+'%');
|
||||
txt(s,{x:lx+26,y:24,'font-size':6.6,'font-weight':600,fill:MUTED,
|
||||
'letter-spacing':'.06em',class:'fade',style:`animation-delay:${.2+si*.06}s`},name);
|
||||
lx+=Math.max(88,name.length*5.4+40);
|
||||
});
|
||||
// bracket: everyone past the first 28 carries some worry
|
||||
const bx=98+20*26-6;
|
||||
el(s,'line',{x1:bx+14,y1:Y0+26-13,x2:bx+14,y2:Y0+4*26+13,stroke:'#8F8E88','stroke-width':1,
|
||||
class:'fade',style:'animation-delay:1s'});
|
||||
[[Y0+26-13],[Y0+4*26+13]].forEach(([yy])=>
|
||||
el(s,'line',{x1:bx+9,y1:yy,x2:bx+14,y2:yy,stroke:'#8F8E88','stroke-width':1,
|
||||
class:'fade',style:'animation-delay:1s'}));
|
||||
txt(s,{x:bx+22,y:Y0+2.5*26-6,'font-size':10.5,'font-weight':800,fill:INK,
|
||||
class:'fade',style:'animation-delay:1.1s'},'72%');
|
||||
txt(s,{x:bx+22,y:Y0+2.5*26+6,'font-size':6.6,'font-weight':600,fill:MUTED,
|
||||
'letter-spacing':'.06em',class:'fade',style:'animation-delay:1.1s'},'CARRY SOME');
|
||||
txt(s,{x:bx+22,y:Y0+2.5*26+15,'font-size':6.6,'font-weight':600,fill:MUTED,
|
||||
'letter-spacing':'.06em',class:'fade',style:'animation-delay:1.1s'},'WORRY');
|
||||
txt(s,{x:360,y:330,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
|
||||
'ONE FACE = ONE PERSON IN A HUNDRED · THE MOUTH IS THE DATA · 28+31+21+12+8 = 100');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ 4 · tick rows ════ fears (multi-select): one tick = one person who picked it.
|
||||
// Horizontal queues, labels stay level — readable at any size.
|
||||
(()=>{
|
||||
const D=[['MORE FOR SAME PAY',51],['UNSUSTAINABLE PACE',46],['QUALITY SLIPPING',41],['REPLACED BY AI',22]];
|
||||
obsReveal('fears',s=>{
|
||||
const X0=160,PX=3.9,y0=i=>66+i*62;
|
||||
D.forEach(([name,v],i)=>{
|
||||
const y=y0(i);
|
||||
txt(s,{x:150,y:y+3,'font-size':7.5,'font-weight':700,fill:'#6A6963','text-anchor':'end',
|
||||
'letter-spacing':'.05em',class:'fade',style:`animation-delay:${i*.08}s`},name);
|
||||
el(s,'line',{x1:X0,y1:y+9,x2:X0+55*PX,y2:y+9,stroke:GRID,'stroke-width':.6,
|
||||
class:'fade',style:`animation-delay:${i*.08}s`});
|
||||
for(let k=0;k<v;k++){
|
||||
const x=X0+k*PX+PX/2,h=9+rnd(k+1,i+2)*6;
|
||||
el(s,'line',{x1:x,y1:y+9,x2:x,y2:y+9-h,stroke:INK,'stroke-width':.9,
|
||||
opacity:.55+rnd(k+3,i+5)*.45,class:'fade',style:`animation-delay:${i*.08+k*.01}s`});
|
||||
if(k%5===4)el(s,'circle',{cx:x,cy:y+13,r:.8,fill:'#C6C5BF',
|
||||
class:'fade',style:`animation-delay:${i*.08+k*.01}s`});
|
||||
}
|
||||
const lab=txt(s,{x:X0+v*PX+10,y:y+4,'font-size':11,'font-weight':800,fill:INK,
|
||||
class:'fade',style:`animation-delay:${.4+i*.08}s`},v);
|
||||
tip(lab,`${name} — ${v} of 100 · they could pick several`);
|
||||
});
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:.9s'},
|
||||
'ONE TICK = ONE PERSON IN A HUNDRED · MULTI-SELECT · DOT MARKS EVERY FIFTH');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ 5 · hourglass stream ════ ambivalence narrowing: 100 → 77 → 51, one tick = one person.
|
||||
(()=>{
|
||||
const STAGE=[['ALL RESPONDENTS',100],['PICKED POSITIVE AND NEGATIVE',77],['SAID "CONFLICTED" OUTRIGHT',51]];
|
||||
obsReveal('hourglass',s=>{
|
||||
const CXm=175,sy=k=>50+k*104,w=c=>c/100*300;
|
||||
STAGE.forEach(([name,c],k)=>{
|
||||
const y=sy(k),hw=w(c)/2;
|
||||
for(let t=0;t<c;t++){
|
||||
const x=CXm-hw+(t+.5)/c*hw*2+(rnd(t+1,k+3)-.5)*2.4;
|
||||
el(s,'line',{x1:x,y1:y-7,x2:x,y2:y+7,stroke:INK,'stroke-width':.8,
|
||||
opacity:.45+rnd(t+2,k+5)*.5,class:'fade',style:`animation-delay:${k*.12+t*.004}s`});
|
||||
}
|
||||
if(k<2){
|
||||
const hw1=w(STAGE[k+1][1])/2;
|
||||
for(let t=0;t<34;t++){
|
||||
const xt=CXm+(rnd(t+1,k*7+1)-.5)*2*hw*.94;
|
||||
const xb=CXm+(rnd(t+3,k*7+5)-.5)*2*hw1*.94;
|
||||
el(s,'path',{d:`M${xt} ${y+9} C${xt} ${y+44} ${xb} ${sy(k+1)-44} ${xb} ${sy(k+1)-9}`,
|
||||
fill:'none',stroke:'#B0AFA9','stroke-width':.5,opacity:.32,pathLength:1,
|
||||
class:'draw',style:`animation-delay:${.2+k*.15+t*.008}s;animation-duration:.8s`});
|
||||
}
|
||||
const pct=Math.round(STAGE[k+1][1]/c*100);
|
||||
txt(s,{x:16,y:(y+sy(k+1))/2+3,'font-size':9,'font-weight':800,fill:'#8F8E88',
|
||||
class:'fade',style:`animation-delay:${.5+k*.15}s`},pct+'%');
|
||||
txt(s,{x:16,y:(y+sy(k+1))/2+14,'font-size':6,'font-weight':600,fill:'#C6C5BF',
|
||||
'letter-spacing':'.06em',class:'fade',style:`animation-delay:${.5+k*.15}s`},'STAY IN');
|
||||
}
|
||||
el(s,'line',{x1:CXm+hw+6,y1:y,x2:332,y2:y,stroke:'#DEDDD6','stroke-width':.8,
|
||||
class:'fade',style:`animation-delay:${.3+k*.12}s`});
|
||||
txt(s,{x:336,y:y-2,'font-size':6.8,'font-weight':700,fill:'#4A4944',
|
||||
'letter-spacing':'.04em',class:'fade',style:`animation-delay:${.35+k*.12}s`},name);
|
||||
txt(s,{x:336,y:y+11,'font-size':11,'font-weight':800,fill:INK,
|
||||
class:'fade',style:`animation-delay:${.4+k*.12}s`},c);
|
||||
});
|
||||
txt(s,{x:200,y:322,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade',style:'animation-delay:1.1s'},
|
||||
'ONE TICK = ONE PERSON · THE AVERAGE PERSON PICKED 5+ EMOTIONS · ONE PICKED 13');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ 6 · brand spectrum ════ NPS by seniority: −49 / −23 / −5.
|
||||
// The template's competitor dots are dropped — Lenny has no such data, we don't invent it.
|
||||
(()=>{
|
||||
const ROWS=[['NEVER AGAIN','GLADLY'],['NEVER AGAIN','GLADLY'],['NEVER AGAIN','GLADLY']];
|
||||
const RANK=['SENIOR / STAFF IC','VP','FOUNDER'];
|
||||
const NPS=[-49,-23,-5];
|
||||
const US=NPS.map(v=>(v+60)/80); // scale −60 … +20
|
||||
const X0=118,X1=342,Y=i=>66+i*72,px=t=>X0+t*(X1-X0);
|
||||
obsReveal('spectrum',s=>{
|
||||
let d=`M${px(US[0])} ${Y(0)}`;
|
||||
for(let i=1;i<3;i++)
|
||||
d+=` C${px(US[i-1])} ${Y(i-1)+36} ${px(US[i])} ${Y(i)-36} ${px(US[i])} ${Y(i)}`;
|
||||
el(s,'path',{d,fill:'none',stroke:'#F0EFEB','stroke-width':30,'stroke-linecap':'round',
|
||||
'stroke-linejoin':'round',opacity:.9,pathLength:1,class:'draw',
|
||||
style:'animation-duration:1.4s'});
|
||||
ROWS.forEach(([a,b],i)=>{
|
||||
const y=Y(i);
|
||||
el(s,'line',{x1:X0,y1:y,x2:X1,y2:y,stroke:'#B0AFA9','stroke-width':1,
|
||||
class:'fade',style:`animation-delay:${i*.08}s`});
|
||||
[X0,X1].forEach(x=>el(s,'line',{x1:x,y1:y-4,x2:x,y2:y+4,stroke:'#B0AFA9','stroke-width':1,
|
||||
class:'fade',style:`animation-delay:${i*.08}s`}));
|
||||
txt(s,{x:X0-14,y:y+3,'font-size':8,'font-weight':600,fill:'#6A6963','text-anchor':'end',
|
||||
'letter-spacing':'.1em',class:'fade',style:`animation-delay:${i*.08}s`},a);
|
||||
txt(s,{x:X1+14,y:y+3,'font-size':8,'font-weight':600,fill:'#6A6963',
|
||||
'letter-spacing':'.1em',class:'fade',style:`animation-delay:${i*.08}s`},b);
|
||||
const xz=px((0+60)/80);
|
||||
el(s,'line',{x1:xz,y1:y-5,x2:xz,y2:y+5,stroke:'#C6C5BF','stroke-width':1,
|
||||
'stroke-dasharray':'2 2',class:'fade',style:`animation-delay:${.2+i*.08}s`});
|
||||
const us=el(s,'circle',{cx:px(US[i]),cy:y,r:8,fill:INK,
|
||||
class:'pop',style:`animation-delay:${.5+i*.1}s`});
|
||||
tip(us,`${RANK[i]} — NPS ${NPS[i]}`);
|
||||
txt(s,{x:px(US[i]),y:y-14,'font-size':9.5,'font-weight':800,fill:INK,'text-anchor':'middle',
|
||||
style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:${.6+i*.1}s`,
|
||||
class:'fade'},NPS[i]);
|
||||
txt(s,{x:px(US[i]),y:y+22,'font-size':7,'font-weight':700,fill:MUTED,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${.65+i*.1}s`},RANK[i]);
|
||||
});
|
||||
txt(s,{x:X0,y:284,'font-size':7.5,'font-weight':600,fill:'#B0AFA9','letter-spacing':'.12em',
|
||||
class:'fade',style:'animation-delay:.9s'},'● NPS BY RANK ┊ ZERO — WHERE YES BALANCES NO');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ 7 · radial convergence ════ "the industry in a sentence": chaos 30 / too fast 17 /
|
||||
// bubble 12 / excitement 11 / everything else 30. Leakage off — shares are exact.
|
||||
(()=>{
|
||||
const N=100,CX=200,CY=162,R=116;
|
||||
const THEME=[['CHAOS · UNCERTAIN',30,-75],['TOO FAST TO KEEP UP',17,-3],['AI BUBBLE TALK',12,69],['EXCITEMENT',11,141],['EVERYTHING ELSE',30,213]];
|
||||
obsReveal('converge',s=>{
|
||||
const blocks=THEME.flatMap(([,n],h)=>Array(n).fill(h));
|
||||
const hubOf=i=>blocks[i];
|
||||
const hubPt=h=>pol(CX,CY,34,THEME[h][2]);
|
||||
const counts=[0,0,0,0,0];
|
||||
for(let i=0;i<N;i++)counts[hubOf(i)]++;
|
||||
for(let i=0;i<N;i++){
|
||||
const deg=i/N*360-90,[px,py]=pol(CX,CY,R,deg);
|
||||
const h=hubOf(i),[hx,hy]=hubPt(h);
|
||||
const c1x=CX+(px-CX)*.42,c1y=CY+(py-CY)*.42;
|
||||
const c2x=CX+(hx-CX)*.3,c2y=CY+(hy-CY)*.3;
|
||||
el(s,'path',{d:`M${px} ${py} C${c1x} ${c1y} ${c2x} ${c2y} ${hx} ${hy}`,
|
||||
fill:'none',stroke:'#A8A7A0','stroke-width':.7,opacity:.55,pathLength:1,
|
||||
class:'draw',style:`animation-delay:${i*.01}s`});
|
||||
el(s,'circle',{cx:px,cy:py,r:1.6,fill:'#6A6963',
|
||||
class:'pop',style:`animation-delay:${i*.01}s`});
|
||||
}
|
||||
THEME.forEach(([name,,deg],h)=>{
|
||||
const [hx,hy]=hubPt(h);
|
||||
const grey=name==='EVERYTHING ELSE';
|
||||
const hub=el(s,'circle',{cx:hx,cy:hy,r:Math.sqrt(counts[h])*1.55,
|
||||
fill:grey?'#B0AFA9':INK,
|
||||
class:'pop',style:`animation-delay:${.5+h*.08}s`});
|
||||
tip(hub,`${name} — ${counts[h]} of 100 answers`);
|
||||
const [tx,ty]=pol(CX,CY,R+26,deg);
|
||||
txt(s,{x:tx,y:ty,'font-size':7.5,'font-weight':800,fill:grey?'#8F8E88':INK,'text-anchor':'middle',
|
||||
'dominant-baseline':'middle','letter-spacing':'.06em',
|
||||
style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:${.6+h*.08}s`,
|
||||
class:'fade'},`${name} · ${counts[h]}`);
|
||||
const [gx,gy]=pol(CX,CY,R+14,deg);
|
||||
el(s,'line',{x1:hx,y1:hy,x2:gx,y2:gy,stroke:'#C6C5BF','stroke-width':.7,
|
||||
'stroke-dasharray':'1 3',class:'fade',style:`animation-delay:${.7+h*.08}s`});
|
||||
});
|
||||
txt(s,{x:200,y:314,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade',style:'animation-delay:1.2s'},
|
||||
'ONE RIM NODE = ONE ANSWER · THE CHAOS QUOTES SPLIT 37 POSITIVE / 37 NEGATIVE / 26 NEUTRAL');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ 8 · ballot rings ════ AI productivity: 82 say at least moderately better,
|
||||
// 49 of those say very or extremely. Two dials, one story.
|
||||
(()=>{
|
||||
const D=[['AT LEAST MODERATELY BETTER',82],['VERY OR EXTREMELY BETTER',49]];
|
||||
obsReveal('rings',s=>{
|
||||
D.forEach(([name,v],i)=>{
|
||||
const cx=214+i*292,cy=134,R0=64;
|
||||
for(let k=0;k<100;k++){
|
||||
const a=k*3.6-90,picked=k<v;
|
||||
const len=picked?12+rnd(k+1,i+2)*6:4.5+rnd(k+1,i+5)*2;
|
||||
const [x1,y1]=pol(cx,cy,R0,a),[x2,y2]=pol(cx,cy,R0+len,a);
|
||||
el(s,'line',{x1,y1,x2,y2,stroke:picked?INK:'#CFCEC7','stroke-width':picked?.95:.55,
|
||||
class:'fade',style:`animation-delay:${i*.1+k*.006}s`});
|
||||
if(k%10===0){
|
||||
const [dx,dy]=pol(cx,cy,R0-5,a);
|
||||
el(s,'circle',{cx:dx,cy:dy,r:.8,fill:'#C6C5BF',
|
||||
class:'fade',style:`animation-delay:${i*.1+k*.006}s`});
|
||||
}
|
||||
}
|
||||
const aEnd=v*3.6-90,[ex,ey]=pol(cx,cy,R0+18,aEnd);
|
||||
el(s,'circle',{cx:ex,cy:ey,r:2.2,fill:INK,class:'pop',style:`animation-delay:${.5+i*.1}s`});
|
||||
const num=txt(s,{x:cx,y:cy+2,'font-size':24,'font-weight':800,fill:INK,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${.4+i*.1}s`},v);
|
||||
tip(num,`${name} — ${v} of 100`);
|
||||
txt(s,{x:cx,y:cy+18,'font-size':7,'font-weight':600,fill:MUTED,'text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade',style:`animation-delay:${.4+i*.1}s`},'OF 100');
|
||||
txt(s,{x:cx,y:cy+R0+42,'font-size':7.5,'font-weight':700,fill:'#55554F','text-anchor':'middle',
|
||||
'letter-spacing':'.06em',class:'fade',style:`animation-delay:${.5+i*.1}s`},name);
|
||||
});
|
||||
txt(s,{x:360,y:290,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1s'},
|
||||
'ONE TICK = ONE PERSON · TWELVE O’CLOCK IS ZERO · THE RIGHT DIAL LIVES INSIDE THE LEFT');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,375 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-Hans">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>七月经营财报 — Lieflat R04 Demo</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Oswald:wght@500;600;700&family=Noto+Sans+SC:wght@400;500;700;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
LIEFLAT 报告模板 04 · 月度经营财报 Demo(中文版)
|
||||
场景:月度经营 / 投资人月更 / 财务复盘
|
||||
排版参考:"2017 Website Traffic" 浅蓝海报(左侧竖排压缩大写
|
||||
书脊、右上角对齐节标题 + 细规线、底部 NOTABLE
|
||||
STATISTICS 大数统计行)—— 只学排版和字体,浅蓝不学。
|
||||
中西混排:数字走 Oswald(保留压缩感),汉字走 Noto Sans SC 黑体。
|
||||
汉字没有压缩字形,书脊改重黑体 + 收紧字距。
|
||||
色板正本 = color-presets.js 的 PORCELAIN 一套(青瓷蓝,明度即数据)。
|
||||
彩色加墨规则(INK_BOOST):数据发丝线宽 ×1.8,透明度地板 .85。
|
||||
图表正本 = lieflat gallery:
|
||||
图A F2 hairline line · basics-gallery.html
|
||||
图B F10 dot heat · basics-gallery.html
|
||||
图C F4 tick donut · basics-gallery.html
|
||||
图D F5 tick rows · basics-gallery.html
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
:root{
|
||||
/* PORCELAIN 角色 token(结构灰一律为文字色的透明档)*/
|
||||
--bg:rgb(247,242,235); --ink:rgb(8,31,92); --muted:rgba(8,31,92,.60); --faint:rgba(8,31,92,.32); --grid:rgba(8,31,92,.16);
|
||||
--num:'Oswald','Noto Sans SC',sans-serif;
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);color:var(--ink);font-family:'Inter','Noto Sans SC',sans-serif;
|
||||
-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums lining-nums;
|
||||
display:flex;justify-content:center;padding:56px 24px}
|
||||
.sheet{width:1080px;max-width:1080px;display:grid;grid-template-columns:110px 1fr}
|
||||
|
||||
/* ── 书脊:重黑体竖排(学参考图4 的压缩大写)── */
|
||||
.spine{position:relative;border-right:1px solid var(--ink)}
|
||||
.spine .t{position:absolute;top:0;left:14px;writing-mode:vertical-rl;
|
||||
font-family:'Noto Sans SC',sans-serif;font-weight:900;font-size:56px;line-height:1.1;
|
||||
letter-spacing:.06em;white-space:nowrap}
|
||||
.spine .b{position:absolute;bottom:0;left:48px;writing-mode:vertical-rl;
|
||||
font-size:9px;font-weight:700;letter-spacing:.26em;color:var(--muted)}
|
||||
|
||||
.content{padding-left:40px}
|
||||
|
||||
/* ── 顶注 ── */
|
||||
.tophead{display:flex;gap:18px;align-items:baseline;border-bottom:1px solid var(--ink);
|
||||
padding-bottom:10px;margin-bottom:40px}
|
||||
.tophead .site{font-family:var(--num);font-weight:600;font-size:13px;
|
||||
letter-spacing:.08em;text-transform:uppercase}
|
||||
.tophead .sep{color:var(--faint)}
|
||||
.tophead .tag{font-size:10px;font-weight:600;letter-spacing:.12em;color:var(--muted)}
|
||||
|
||||
/* ── 节标题:右对齐 + 引线(学参考图4)── */
|
||||
.secthead{display:flex;align-items:baseline;gap:14px;margin:44px 0 8px}
|
||||
.secthead .line{flex:1;border-top:1px solid var(--ink)}
|
||||
.secthead .t{font-family:'Noto Sans SC',sans-serif;font-weight:700;font-size:15px;
|
||||
letter-spacing:.2em}
|
||||
.secthead.first{margin-top:0}
|
||||
|
||||
.claim{font-size:13px;font-weight:700;color:var(--ink);margin:4px 0 2px}
|
||||
.fig svg{width:100%;height:auto;display:block}
|
||||
svg text{font-family:'Inter','Noto Sans SC',sans-serif}
|
||||
.srcline{font-size:9px;font-weight:600;letter-spacing:.1em;
|
||||
color:var(--faint);margin-top:8px}
|
||||
|
||||
.duo{display:grid;grid-template-columns:1.25fr 1fr;gap:44px}
|
||||
|
||||
/* ── 底部统计行(学参考图4 的 NOTABLE STATISTICS)── */
|
||||
.stats{display:grid;grid-template-columns:1fr 1fr 1.2fr 1fr;gap:0;margin-top:14px;
|
||||
border-top:1px solid var(--ink)}
|
||||
.stats>div{padding:22px 20px 6px;border-right:1px dotted var(--faint)}
|
||||
.stats>div:first-child{padding-left:0}
|
||||
.stats>div:last-child{border-right:0;padding-right:0}
|
||||
.stats .kpi{margin-bottom:18px}
|
||||
.stats .kpi .v{font-family:var(--num);font-weight:700;font-size:38px;line-height:1}
|
||||
.stats .kpi .r{font-size:10px;font-weight:700;color:var(--muted);margin-top:4px}
|
||||
.stats .kpi .l{font-size:9.5px;font-weight:600;letter-spacing:.12em;
|
||||
color:var(--faint);margin-top:3px}
|
||||
.stats h4{font-family:'Noto Sans SC',sans-serif;font-weight:700;font-size:12.5px;
|
||||
letter-spacing:.14em;margin-bottom:10px}
|
||||
.ranked .row{display:flex;gap:12px;align-items:baseline;padding:8px 0;
|
||||
border-bottom:1px dotted var(--faint)}
|
||||
.ranked .row:last-child{border-bottom:0}
|
||||
.ranked .n{font-family:var(--num);font-weight:700;font-size:24px;min-width:38px}
|
||||
.ranked .d{font-size:10px;line-height:1.7;color:var(--muted)}
|
||||
.ranked .d b{color:var(--ink)}
|
||||
|
||||
.foot{margin-top:48px;border-top:1px solid var(--ink);padding-top:12px;
|
||||
display:flex;justify-content:space-between;font-size:9px;font-weight:600;
|
||||
letter-spacing:.12em;color:var(--muted)}
|
||||
|
||||
/* ── 动画(mono-tokens MOTION.css 原样)── */
|
||||
.pop{transform-box:fill-box;transform-origin:center;animation:pop .5s cubic-bezier(.2,.7,.3,1.3) both}
|
||||
@keyframes pop{from{transform:scale(0)}to{transform:none}}
|
||||
.fade{animation:fade .9s ease both}
|
||||
@keyframes fade{from{opacity:0}}
|
||||
.draw{stroke-dasharray:1;stroke-dashoffset:1;animation:draw 1s cubic-bezier(.4,0,.2,1) both}
|
||||
@keyframes draw{to{stroke-dashoffset:0}}
|
||||
@media(max-width:640px){body{padding:24px 10px}}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.pop,.fade{animation:none}
|
||||
.draw{animation:none;stroke-dasharray:none;stroke-dashoffset:0}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sheet">
|
||||
|
||||
<!-- ══ 书脊 ══ -->
|
||||
<div class="spine">
|
||||
<div class="t">七月经营财报</div>
|
||||
<div class="b">财务 · 2026</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="tophead">
|
||||
<span class="site">YOUR COMPANY</span><span class="sep">|</span>
|
||||
<span class="tag">2026 年 7 月财务数据 · 全部指标环比上月</span>
|
||||
</div>
|
||||
|
||||
<!-- ══ 图A · F2 hairline line:本月逐日 ══
|
||||
模板:basics-gallery.html · "Thirty days of sign-ups" -->
|
||||
<div class="secthead first"><span class="line"></span><span class="t">每日收入</span></div>
|
||||
<div class="claim">收入在月中和月末各出现一次结算高峰,基本盘保持稳定</div>
|
||||
<div class="fig"><svg id="dayline" viewBox="0 0 400 300" preserveAspectRatio="xMidYMid meet"></svg></div>
|
||||
<div class="srcline">HAIRLINE LINE · 一点 = 一天 · 空心 = 周末 · 单位:万元</div>
|
||||
|
||||
<!-- ══ 图B · F10 dot heat:一周节奏 ══
|
||||
模板:basics-gallery.html · "When support gets loud" -->
|
||||
<div class="secthead"><span class="line"></span><span class="t">回款节奏</span></div>
|
||||
<div class="duo">
|
||||
<div>
|
||||
<div class="claim">周二上午十点,是本月回款最集中的时段</div>
|
||||
<div class="fig"><svg id="dotheat" viewBox="0 0 400 300" preserveAspectRatio="xMidYMid meet"></svg></div>
|
||||
<div class="srcline">DOT HEAT · 7 天 × 12 小时 · 点面积 = 回款笔数</div>
|
||||
</div>
|
||||
<div>
|
||||
<!-- ══ 图C · F4 tick donut:来源分布 ══
|
||||
模板:basics-gallery.html · "Where the traffic comes from" -->
|
||||
<div class="claim">订阅收入仍是最大来源,服务收入贡献第二增长曲线</div>
|
||||
<div class="fig"><svg id="donut" viewBox="0 0 330 300" preserveAspectRatio="xMidYMid meet"></svg></div>
|
||||
<div class="srcline">TICK DONUT · 一格 = 收入构成的 1%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ 值得记住的数字 ══ -->
|
||||
<div class="secthead"><span class="line"></span><span class="t">值得记住的数字</span></div>
|
||||
<div class="stats">
|
||||
<div>
|
||||
<div class="kpi"><div class="v">¥2.84M</div><div class="r">环比 +12.6%</div><div class="l">营业收入</div></div>
|
||||
<div class="kpi"><div class="v">¥1.94M</div><div class="r">毛利率 68.4%</div><div class="l">毛利</div></div>
|
||||
<div class="kpi"><div class="v">¥0.62M</div><div class="r">连续第三个月为正</div><div class="l">经营现金流</div></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="kpi"><div class="v">68.4%</div><div class="r">较 6 月的 65.2% 上升</div><div class="l">毛利率</div></div>
|
||||
<div class="kpi"><div class="v">14.2</div><div class="r">按当前现金消耗估算</div><div class="l">现金可支撑月数</div></div>
|
||||
</div>
|
||||
<div>
|
||||
<h4>各业务线毛利贡献</h4>
|
||||
<!-- ══ 图D · F5 tick rows(迷你)══
|
||||
模板:basics-gallery.html · "Six teams, shipped and counted" -->
|
||||
<div class="fig"><svg id="rows" viewBox="0 0 260 190" preserveAspectRatio="xMidYMid meet"></svg></div>
|
||||
<div class="srcline" style="text-align:left">TICK ROWS · 一格 = 10 万元毛利</div>
|
||||
</div>
|
||||
<div class="ranked">
|
||||
<h4>本月收入贡献最高</h4>
|
||||
<div class="row"><div class="n">46%</div><div class="d"><b>企业订阅</b> —— 占全月收入的 46%</div></div>
|
||||
<div class="row"><div class="n">31%</div><div class="d"><b>实施服务</b> —— 连续第二个月增长</div></div>
|
||||
<div class="row"><div class="n">15%</div><div class="d"><b>API 用量</b> —— 受大客户扩容带动</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="foot">
|
||||
<span>月度经营财报 Demo · 2026 年 7 月</span>
|
||||
<span>演示数据 · 管理账 + Billing + Cash Flow · 8 月 1 日拉取</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ── mono-tokens 内联(色板 / 几何 / 工具函数与正本一致)── */
|
||||
const BG='rgb(247,242,235)',TXT='rgb(8,31,92)',MUT='rgba(8,31,92,.60)',LAB='rgba(8,31,92,.72)',FAINT='rgba(8,31,92,.32)';
|
||||
const FLOOR='rgba(8,31,92,.24)',QUIET='rgba(8,31,92,.15)',GRID='rgba(8,31,92,.16)';
|
||||
const DATA='rgb(51,78,172)',DATA2='rgb(112,150,209)',HERO='rgb(8,31,92)',FAINTDATA='rgb(186,214,235)';
|
||||
const CAT4=['rgb(8,31,92)','rgb(51,78,172)','rgb(112,150,209)','rgb(186,214,235)'],HEAT=['rgb(51,78,172)','rgb(112,150,209)','rgb(186,214,235)'];
|
||||
const NS='http://www.w3.org/2000/svg';
|
||||
const el=(p,t,a)=>{const n=document.createElementNS(NS,t);for(const k in a)n.setAttribute(k,a[k]);p.appendChild(n);return n};
|
||||
const txt=(p,a,s)=>{const n=el(p,'text',a);n.textContent=s;return n};
|
||||
const tip=(n,s)=>{const t=document.createElementNS(NS,'title');t.textContent=s;n.appendChild(t)};
|
||||
const rnd=(i,k)=>Math.abs(((i*73856093)^(k*19349663))%1000)/1000;
|
||||
const D2R=Math.PI/180;
|
||||
const pol=(cx,cy,r,deg)=>[cx+r*Math.cos(deg*D2R),cy+r*Math.sin(deg*D2R)];
|
||||
const obsReveal=(id,fn)=>{
|
||||
const n=document.getElementById(id);
|
||||
const go=()=>{n.innerHTML='';fn(n)};
|
||||
const io=new IntersectionObserver(es=>{if(es[0].isIntersecting){go();io.disconnect()}},{threshold:.3});
|
||||
io.observe(n); n.style.cursor='pointer'; n.addEventListener('click',go);
|
||||
};
|
||||
|
||||
/* ════ 图A · F2 hairline line ════
|
||||
结构照搬 basics-gallery "hairline line":barcode 日历地板 +
|
||||
发丝折线 + 逐日圆点(周末空心),结算高峰放大标数。 */
|
||||
(()=>{
|
||||
const N=31;
|
||||
const val=d=>{
|
||||
const spike=(d===5||d===6)?38:(d===19?30:0); // 两次结算高峰
|
||||
return 58+10*Math.sin(d/5.2)+rnd(d+1,5)*10+spike;
|
||||
};
|
||||
obsReveal('dayline',s=>{
|
||||
const x=d=>26+d*11.4,base=254,map=v=>base-v*2.1;
|
||||
for(let d=0;d<N;d++)
|
||||
el(s,'line',{x1:x(d),y1:base,x2:x(d),y2:base-7,stroke:FLOOR,'stroke-width':.6,
|
||||
class:'fade',style:`animation-delay:${d*.008}s`});
|
||||
el(s,'line',{x1:20,y1:base,x2:380,y2:base,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
const vs=Array.from({length:N},(_,d)=>val(d));
|
||||
const top=[];
|
||||
for(const d of [...vs.keys()].sort((a,b)=>vs[b]-vs[a])){
|
||||
if(top.every(t=>Math.abs(t-d)>=5))top.push(d);
|
||||
if(top.length===2)break;
|
||||
}
|
||||
const pts=vs.map((v,d)=>`${x(d)} ${map(v)}`).join(' L ');
|
||||
el(s,'path',{d:'M'+pts,fill:'none',stroke:DATA,'stroke-width':2.2,pathLength:1,
|
||||
class:'draw',style:'animation-duration:1.2s'});
|
||||
vs.forEach((v,d)=>{
|
||||
const weekend=d%7===3||d%7===4,big=top.includes(d);
|
||||
const dot=el(s,'circle',{cx:x(d),cy:map(v),r:big?4.2:2.1,
|
||||
fill:weekend?BG:(big?HERO:DATA),stroke:big?HERO:DATA,'stroke-width':weekend?1.4:0,
|
||||
class:'pop',style:`animation-delay:${.2+d*.03}s`});
|
||||
tip(dot,`7 月 ${d+1} 日 —— ${Math.round(v)} 万元收入`);
|
||||
if(big)txt(s,{x:x(d),y:map(v)-11,'font-size':9.5,'font-weight':800,fill:TXT,'text-anchor':'middle',
|
||||
style:`paint-order:stroke;stroke:${BG};stroke-width:3px;animation-delay:${1+d*.01}s`,
|
||||
class:'fade'},Math.round(v));
|
||||
});
|
||||
[[0,'7月1日'],[14,'7月15日'],[30,'7月31日']].forEach(([d,m])=>
|
||||
txt(s,{x:x(d),y:base+18,'font-size':7.5,'font-weight':600,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.06em',class:'fade'},m));
|
||||
txt(s,{x:200,y:294,'font-size':7.5,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade',style:'animation-delay:1.1s'},
|
||||
'一点 = 一天 · 空心 = 周末 · 标数 = 结算高峰');
|
||||
});
|
||||
})();
|
||||
|
||||
/* ════ 图B · F10 dot heat ════
|
||||
结构照搬 basics-gallery "dot heat":7×12 网格,点面积 = 回款笔数,
|
||||
静默格留一粒极小点,最热格虚线圈标数。 */
|
||||
(()=>{
|
||||
const DAY=['周一','周二','周三','周四','周五','周六','周日'];
|
||||
obsReveal('dotheat',s=>{
|
||||
const x0=j=>64+j*27,y0=i=>50+i*30;
|
||||
let max=0,mi=0,mj=0;
|
||||
const v=(i,j)=>{
|
||||
const day=i<5?(i===1?1.25:1):.32;
|
||||
const hour=Math.exp(-((j-3.4)**2)/6)+.6*Math.exp(-((j-8.6)**2)/5);
|
||||
return Math.round(day*hour*20*(0.6+rnd(i*12+j+1,j+3)*.8));
|
||||
};
|
||||
for(let i=0;i<7;i++)for(let j=0;j<12;j++){const t=v(i,j);if(t>max){max=t;mi=i;mj=j}}
|
||||
for(let i=0;i<7;i++){
|
||||
txt(s,{x:50,y:y0(i)+3,'font-size':8,'font-weight':700,fill:LAB,'text-anchor':'end',
|
||||
'letter-spacing':'.04em',class:'fade',style:`animation-delay:${i*.05}s`},DAY[i]);
|
||||
for(let j=0;j<12;j++){
|
||||
const t=v(i,j),x=x0(j),y=y0(i);
|
||||
if(!t){el(s,'circle',{cx:x,cy:y,r:.8,fill:QUIET,
|
||||
class:'pop',style:`animation-delay:${i*.05+j*.015}s`});continue}
|
||||
const hero=i===mi&&j===mj;
|
||||
const dot=el(s,'circle',{cx:x,cy:y,r:1.2+Math.sqrt(t)*2.1,
|
||||
fill:t>max*.66?HEAT[0]:t>max*.33?HEAT[1]:HEAT[2],
|
||||
class:'pop',style:`animation-delay:${i*.05+j*.015}s`});
|
||||
tip(dot,`${DAY[i]} ${8+j}:00 —— ${t} 笔回款`);
|
||||
if(hero)el(s,'circle',{cx:x,cy:y,r:1.2+Math.sqrt(t)*2.1+3.4,fill:'none',
|
||||
stroke:TXT,'stroke-width':1,'stroke-dasharray':'2 3',
|
||||
class:'fade',style:'animation-delay:1s'});
|
||||
}
|
||||
}
|
||||
for(let j=0;j<12;j+=2)
|
||||
txt(s,{x:x0(j),y:y0(6)+26,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${j*.02}s`},(8+j)+':00');
|
||||
txt(s,{x:200,y:296,'font-size':7.5,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade',style:'animation-delay:1.1s'},
|
||||
`点面积 = 回款笔数 · 虚线圈 = 峰值 ${max} 笔 · 极小点 = 安静时段`);
|
||||
});
|
||||
})();
|
||||
|
||||
/* ════ 图C · F4 tick donut ════
|
||||
结构照搬 basics-gallery "tick donut":1 tick = 1%,
|
||||
四段来源用灰阶 ladder 分段。 */
|
||||
(()=>{
|
||||
const D=[['订阅',46,CAT4[0]],['服务',31,CAT4[1]],['API',15,CAT4[2]],['其他',8,CAT4[3]]];
|
||||
obsReveal('donut',s=>{
|
||||
const cx=170,cy=140,R0=58;
|
||||
let k0=0;
|
||||
D.forEach(([name,v,shade],si)=>{
|
||||
for(let k=0;k<v;k++){
|
||||
const idx=k0+k,a=idx*3.6-90;
|
||||
const len=9+rnd(idx+1,si+2)*5.5;
|
||||
const [x1,y1]=pol(cx,cy,R0,a),[x2,y2]=pol(cx,cy,R0+len,a);
|
||||
el(s,'line',{x1,y1,x2,y2,stroke:shade,'stroke-width':1.9,
|
||||
class:'fade',style:`animation-delay:${idx*.012}s`});
|
||||
if(idx%10===0){
|
||||
const [dx,dy]=pol(cx,cy,R0-5,a);
|
||||
el(s,'circle',{cx:dx,cy:dy,r:.8,fill:FAINT,
|
||||
class:'fade',style:`animation-delay:${idx*.012}s`});
|
||||
}
|
||||
}
|
||||
const mid=(k0+v/2)*3.6-90,[lx,ly]=pol(cx,cy,R0+34,mid),[gx,gy]=pol(cx,cy,R0+18,mid);
|
||||
el(s,'line',{x1:gx,y1:gy,x2:lx,y2:ly,stroke:FAINT,'stroke-width':.7,
|
||||
'stroke-dasharray':'1 3',class:'fade',style:`animation-delay:${.6+si*.1}s`});
|
||||
const anchor=Math.cos(mid*D2R)>0.3?'start':Math.cos(mid*D2R)<-0.3?'end':'middle';
|
||||
const lab=txt(s,{x:lx,y:ly+3,'font-size':8.5,'font-weight':800,fill:shade,'text-anchor':anchor,
|
||||
'letter-spacing':'.04em',style:`paint-order:stroke;stroke:${BG};stroke-width:3px;animation-delay:${.65+si*.1}s`,
|
||||
class:'fade'},`${name} · ${v}`);
|
||||
tip(lab,`${name}流量 —— 占 ${v}%`);
|
||||
k0+=v;
|
||||
});
|
||||
txt(s,{x:cx,y:cy-2,'font-size':20,'font-weight':800,fill:TXT,'text-anchor':'middle',
|
||||
class:'fade',style:'animation-delay:.9s'},'100');
|
||||
txt(s,{x:cx,y:cy+13,'font-size':7.5,'font-weight':600,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade',style:'animation-delay:.9s'},'格 · 一格 = 1%');
|
||||
txt(s,{x:170,y:288,'font-size':7.5,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade',style:'animation-delay:1.1s'},
|
||||
'每十格一个圆点 · 顺时针读');
|
||||
});
|
||||
})();
|
||||
|
||||
/* ════ 图D · F5 tick rows(迷你版)════
|
||||
结构照搬 basics-gallery "tick rows":1 tick = 10 万元毛利。 */
|
||||
(()=>{
|
||||
const D=[['订阅',13],['服务',9],['API',6],['渠道',4],['其他',2]];
|
||||
obsReveal('rows',s=>{
|
||||
const y0=i=>28+i*34,X0=72,PX=4.6;
|
||||
D.forEach(([name,v],i)=>{
|
||||
const y=y0(i);
|
||||
txt(s,{x:64,y:y+3,'font-size':7.5,'font-weight':700,fill:LAB,'text-anchor':'end',
|
||||
'letter-spacing':'.04em',class:'fade',style:`animation-delay:${i*.08}s`},name);
|
||||
el(s,'line',{x1:X0,y1:y+8,x2:X0+34*PX,y2:y+8,stroke:GRID,'stroke-width':.6,
|
||||
class:'fade',style:`animation-delay:${i*.08}s`});
|
||||
for(let k=0;k<v;k++){
|
||||
const x=X0+k*PX+PX/2,h=7+rnd(k+1,i+2)*5;
|
||||
el(s,'line',{x1:x,y1:y+8,x2:x,y2:y+8-h,stroke:DATA,'stroke-width':1.6,
|
||||
opacity:.85+rnd(k+3,i+5)*.15,class:'fade',style:`animation-delay:${i*.08+k*.012}s`});
|
||||
if(k%5===4)el(s,'circle',{cx:x,cy:y+11.5,r:.7,fill:FAINT,
|
||||
class:'fade',style:`animation-delay:${i*.08+k*.012}s`});
|
||||
}
|
||||
const lab=txt(s,{x:X0+v*PX+8,y:y+3,'font-size':9.5,'font-weight':800,fill:TXT,
|
||||
class:'fade',style:`animation-delay:${.4+i*.08}s`},v);
|
||||
tip(lab,`${name} —— ${v * 10} 万元毛利`);
|
||||
});
|
||||
txt(s,{x:130,y:186,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade',style:'animation-delay:.9s'},
|
||||
'一格 = 10 万元毛利 · 每五格一个圆点');
|
||||
});
|
||||
})();
|
||||
|
||||
</script>
|
||||
<script>
|
||||
/* ── fit-width:视口比版心窄时整页等比缩小,不重排 ──
|
||||
报告是一张版面,不是响应式网页。窄屏塌成单栏会得到一个
|
||||
设计时不存在的版式;等比缩小则保留构图,跟 PDF 的「适合宽度」一致。
|
||||
机制同模板 11。 */
|
||||
(()=>{
|
||||
const sheet=document.querySelector('.sheet');
|
||||
const W=1080;
|
||||
const fit=()=>{
|
||||
const cs=getComputedStyle(document.body);
|
||||
const avail=document.documentElement.clientWidth
|
||||
-parseFloat(cs.paddingLeft)-parseFloat(cs.paddingRight);
|
||||
sheet.style.zoom=Math.min(1,avail/W);
|
||||
};
|
||||
fit();
|
||||
addEventListener('resize',fit);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,177 @@
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
MONO TOKENS — 风格的唯一正本
|
||||
所有 mono 图表(Chart.js / ECharts / 手写 SVG)共享这一份。
|
||||
任何文件里出现与本文件冲突的颜色、字体、动画参数,以本文件为准。
|
||||
用法:<script src="mono-tokens.js"></script>,全部挂在 window.MONO 上;
|
||||
也可以直接把本文件内容内联进单文件 HTML(开源分发时推荐内联)。
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
/* ── 1 · 色板 ──────────────────────────────────────────────
|
||||
纸灰底 + 炭黑墨。没有彩色。明度即数据:最重要 = 最黑。 */
|
||||
const INK = '#1C1C1A'; // 墨:主数据、标题、强调
|
||||
const PAPER = '#F0EFEB'; // 纸:页面底色 = 浅卡底色(卡片无边框,靠留白分卡)
|
||||
const MUTED = '#8F8E88'; // 次级文字、副标题
|
||||
const FAINT = '#C6C5BF'; // 来源行、辅助刻度
|
||||
const GRID = '#DEDDD6'; // 网格线、发丝线
|
||||
|
||||
// 7 级灰阶 ladder:多系列时按重要性从黑到浅分配
|
||||
const L = ['#1C1C1A', '#4A4944', '#6A6963', '#8F8E88', '#B0AFA9', '#C6C5BF', '#D8D7D1'];
|
||||
// 5 级简版(waffle 等少系列场景)
|
||||
const LAD = ['#1C1C1A', '#4A4944', '#8F8E88', '#B0AFA9', '#D8D7D1'];
|
||||
|
||||
// 暗卡(dark card)专用:底 #1C1C1A,其上的"墨"反转为纸色
|
||||
const DARK = {
|
||||
bg: '#1C1C1A',
|
||||
ink: '#F0EFEB', // 暗卡上的主数据色
|
||||
muted: '#8F8E88',
|
||||
faint: '#55554F', // 暗卡来源行
|
||||
grid: '#2E2D29', // 暗卡网格 / 发丝线
|
||||
gridSoft: '#2A2925',
|
||||
ladder: ['#F0EFEB', '#DCDAD2', '#C9C7BD', '#B3B0A4', '#8F8E88', '#6A6963', '#4A4944'],
|
||||
};
|
||||
|
||||
/* ── 2 · 字体 ────────────────────────────────────────────── */
|
||||
const FONT = {
|
||||
family: 'Inter',
|
||||
// Google Fonts 引入行(放 <head>):
|
||||
link: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap',
|
||||
title: { size: 16.5, weight: 700, spacing: '-.02em' }, // 卡内 h2
|
||||
titleBig: { size: 19, weight: 700, spacing: '-.02em' }, // 独立大图 h2
|
||||
sub: { size: 11.5, weight: 400 }, // 副标题(图例说明写在这里)
|
||||
src: { size: 9.5, weight: 500, spacing: '.08em' }, // 来源行,全大写
|
||||
value: { weight: 800 }, // 图内数值一律 800
|
||||
axis: { size: 9.5, weight: 600 }, // 轴标签
|
||||
// SVG 内最小字号下限:半宽卡 6.5px,通栏/大图 5.5px。低于下限改 hover 出。
|
||||
minHalf: 6.5, minWide: 5.5,
|
||||
};
|
||||
|
||||
/* ── 3 · 形状 ────────────────────────────────────────────── */
|
||||
const SHAPE = {
|
||||
cardRadius: 24, // 卡片圆角
|
||||
cardPad: '28px 28px 20px',
|
||||
barRadius: 99, // 柱端胶囊圆角(竖柱只圆上端,横柱只圆外端)
|
||||
tooltipRadius: 12,
|
||||
};
|
||||
|
||||
/* ── 4 · 动画性格 ────────────────────────────────────────
|
||||
快进快停,quarticOut / cubicOut,不弹跳(elasticOut 只给波浪入场)。 */
|
||||
const MOTION = {
|
||||
enter: 900, // 常规入场 ms
|
||||
enterSlow: 1200, // 大元素 / 关系图
|
||||
easing: 'quarticOut',
|
||||
staggerDot: 12, // 点阵逐个延迟 ms(8–15 区间)
|
||||
staggerBar: 100, // 条形逐根延迟 ms(80–130 区间)
|
||||
// CSS 动画类(手写 SVG 用):pop 缩放入场 / fade 淡入 / draw 描线
|
||||
css: `
|
||||
.pop{transform-box:fill-box;transform-origin:center;animation:pop .5s cubic-bezier(.2,.7,.3,1.3) both}
|
||||
@keyframes pop{from{transform:scale(0)}to{transform:none}}
|
||||
.fade{animation:fade .9s ease both}
|
||||
@keyframes fade{from{opacity:0}}
|
||||
.draw{stroke-dasharray:1;stroke-dashoffset:1;animation:draw 1s cubic-bezier(.4,0,.2,1) both}
|
||||
@keyframes draw{to{stroke-dashoffset:0}}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.pop,.fade{animation:none}
|
||||
.draw{animation:none;stroke-dasharray:none;stroke-dashoffset:0}
|
||||
}`,
|
||||
};
|
||||
|
||||
/* ── 5 · Tooltip ─────────────────────────────────────────
|
||||
浅卡用黑底纸字,暗卡用纸底黑字。ECharts 直接展开这两个对象。 */
|
||||
const tipLight = { backgroundColor: INK, borderWidth: 0, padding: [10, 14],
|
||||
textStyle: { color: PAPER, fontFamily: 'Inter', fontSize: 12 } };
|
||||
const tipDark = { backgroundColor: PAPER, borderWidth: 0, padding: [10, 14],
|
||||
textStyle: { color: INK, fontFamily: 'Inter', fontSize: 12 } };
|
||||
|
||||
/* ── 6 · 确定性伪随机 ────────────────────────────────────
|
||||
演示数据一律用它,不用 Math.random()——刷新必须长一样,
|
||||
否则截图 / 录屏 / 回归对比全部失效。 */
|
||||
const rnd = (i, k) => Math.abs(((i * 73856093) ^ (k * 19349663)) % 1000) / 1000;
|
||||
|
||||
/* ── 7 · 几何 ────────────────────────────────────────────── */
|
||||
const D2R = Math.PI / 180;
|
||||
const pol = (cx, cy, r, deg) => [cx + r * Math.cos(deg * D2R), cy + r * Math.sin(deg * D2R)];
|
||||
// 环形扇区 path(cornerRadius 自己在调用处用 ECharts sector 或手动倒角)
|
||||
const sect = (cx, cy, r0, r1, a0, a1) => {
|
||||
const big = a1 - a0 > 180 ? 1 : 0;
|
||||
const [xa, ya] = pol(cx, cy, r1, a0), [xb, yb] = pol(cx, cy, r1, a1);
|
||||
const [xc, yc] = pol(cx, cy, r0, a1), [xd, yd] = pol(cx, cy, r0, a0);
|
||||
return `M${xa} ${ya} A${r1} ${r1} 0 ${big} 1 ${xb} ${yb} L${xc} ${yc} A${r0} ${r0} 0 ${big} 0 ${xd} ${yd} Z`;
|
||||
};
|
||||
// 手绘感圆(editorial 系气泡用):圆周叠两个慢波 + 噪声,seed 定形
|
||||
const blob = (x, y, r, seed) => {
|
||||
const n = Math.max(14, Math.round(r * 1.6)), pts = [];
|
||||
for (let t = 0; t < n; t++) {
|
||||
const a = t / n * Math.PI * 2;
|
||||
const w = 1 + .055 * Math.sin(a * 2 + seed * 7) + .04 * Math.sin(a * 3 + seed * 13)
|
||||
+ (rnd(seed + t, 3) - .5) * .03;
|
||||
pts.push([x + Math.cos(a) * r * w, y + Math.sin(a) * r * w]);
|
||||
}
|
||||
let d = `M${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;
|
||||
for (let t = 0; t < n; t++) {
|
||||
const p = pts[t], q = pts[(t + 1) % n];
|
||||
d += ` Q${p[0].toFixed(1)} ${p[1].toFixed(1)} ${((p[0]+q[0])/2).toFixed(1)} ${((p[1]+q[1])/2).toFixed(1)}`;
|
||||
}
|
||||
return d + ' Z';
|
||||
};
|
||||
|
||||
/* ── 8 · SVG 快捷 ────────────────────────────────────────── */
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
const el = (p, t, a) => { const n = document.createElementNS(NS, t);
|
||||
for (const k in a) n.setAttribute(k, a[k]); p.appendChild(n); return n; };
|
||||
const txt = (p, a, s) => { const n = el(p, 'text', a); n.textContent = s; return n; };
|
||||
const tip = (n, s) => { const t = document.createElementNS(NS, 'title');
|
||||
t.textContent = s; n.appendChild(t); };
|
||||
|
||||
/* ── 9 · 统一 reveal:滚入视野才播,点击重播 ──────────────
|
||||
带 timer 登记(keep),重播前清干净,防动画叠加。 */
|
||||
const timers = {};
|
||||
const keep = (id, t) => { (timers[id] = timers[id] || []).push(t); };
|
||||
const obsReveal = (id, fn) => {
|
||||
const n = document.getElementById(id);
|
||||
const go = () => {
|
||||
(timers[id] || []).forEach(clearInterval); timers[id] = [];
|
||||
if (n.tagName === 'svg' || n.tagName === 'SVG') n.innerHTML = '';
|
||||
fn(n);
|
||||
};
|
||||
const io = new IntersectionObserver(es => {
|
||||
if (es[0].isIntersecting) { go(); io.disconnect(); }
|
||||
}, { threshold: .3 });
|
||||
io.observe(n);
|
||||
n.style.cursor = 'pointer';
|
||||
n.addEventListener('click', go);
|
||||
};
|
||||
// ECharts 版 reveal
|
||||
const eReveal = (id, opt) => obsReveal(id, elDom => {
|
||||
const g = echarts.getInstanceByDom(elDom) || echarts.init(elDom);
|
||||
g.clear(); g.setOption(opt);
|
||||
});
|
||||
|
||||
/* ── 10 · 卡片骨架(生成新图时照抄这个结构) ──────────────
|
||||
<div class="card [dark] [wide]">
|
||||
<h2>结论式标题</h2>
|
||||
<div class="sub">副标题 · 图例说明 · 时间范围</div>
|
||||
<div class="ch" id="xx"></div> 或 <svg id="xx" viewBox="0 0 400 320">
|
||||
<div class="src">图型名 · 系列名 · 数据来源(全大写)</div>
|
||||
</div> */
|
||||
const CARD_CSS = `
|
||||
:root{--bg:${PAPER};--dark:${DARK.bg};--ink:${INK};--muted:${MUTED};--faint:${FAINT};--grid:${GRID}}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);font-family:'Inter',sans-serif;color:var(--ink);padding:40px;-webkit-font-smoothing:antialiased}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:22px;max-width:1400px;margin:0 auto}
|
||||
.card{background:var(--bg);border-radius:${SHAPE.cardRadius}px;padding:${SHAPE.cardPad}}
|
||||
.card.dark{background:var(--dark);color:${PAPER}}
|
||||
.card.dark .sub{color:${MUTED}}
|
||||
.card.dark .src{color:${DARK.faint}}
|
||||
.card.wide{grid-column:1/-1}
|
||||
h2{font-weight:${FONT.title.weight};font-size:${FONT.title.size}px;letter-spacing:${FONT.title.spacing};margin-bottom:3px}
|
||||
.sub{font-size:${FONT.sub.size}px;color:var(--muted);margin-bottom:14px}
|
||||
.src{font-size:${FONT.src.size}px;color:var(--faint);margin-top:10px;letter-spacing:${FONT.src.spacing};font-weight:${FONT.src.weight}}
|
||||
.ch{height:320px}
|
||||
svg text{font-family:'Inter',sans-serif}` + MOTION.css;
|
||||
|
||||
global.MONO = { INK, PAPER, MUTED, FAINT, GRID, L, LAD, DARK,
|
||||
FONT, SHAPE, MOTION, tipLight, tipDark,
|
||||
rnd, pol, sect, blob, el, txt, tip, obsReveal, eReveal, keep, CARD_CSS };
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -0,0 +1,49 @@
|
||||
# Lieflat Charts 报告模板目录 · 12 套
|
||||
|
||||
> 报告模板决定整页的叙事顺序、版心、模块密度和阅读速度;页内图表仍须遵守 `catalog.md` 的数据契约。下表的场景是推荐用法,不是硬性限制:同一套版式可以迁移到金融经济、研究、业务、产品和个人记录等不同内容。
|
||||
> 每套模板提供中文和英文两个独立版本:`templates/reports/report-NN.zh.html` 与 `templates/reports/report-NN.en.html`。
|
||||
> 可视化索引在 `templates/reports/index.html`,静态预览在 `docs/assets/reports/`。
|
||||
|
||||
## 选型顺序
|
||||
|
||||
1. 先按报告类型、受众和阅读任务筛选,不按“里面有哪种图”或模板名称里的行业词筛选。
|
||||
2. 再按信息密度、版心宽度、阅读速度和是否需要离线运行筛选。
|
||||
3. 至少比较 3 个候选;候选不足 3 个时比较全部。
|
||||
4. 锁定一个报告模板后保留其整页骨架,不拼接其他报告模板的区块。
|
||||
|
||||
## 模板索引
|
||||
|
||||
| # | 中文名 / English | 常见报告类型 / 可迁移场景 | 版心 | 密度 | 色系 | 依赖 |
|
||||
|---|---|---|---:|---|---|---|
|
||||
| R01 | 调研一页纸 / Survey One-Pager | 调研报告、研究简报、政策 / 市场洞察、白皮书开篇、对外数据发布 | 1080 | 3 图,中等 | Porcelain | 字体联网 |
|
||||
| R02 | 年度里程碑 / Annual Milestones | 年度复盘、业绩 / 财报回顾、投资人更新、产品或项目里程碑 | 980 | 3 图,中等 | Palm | 字体联网 |
|
||||
| R03 | 年度数据海报 / Year in Data | 年度数据报告、经营 / 财务年报、个人年度记录、年度趋势海报 | 1080 | 4 图,较高 | Wire | 字体联网 |
|
||||
| R04 | 月度运营 / Monthly Ops | 月报、业务数据报告、财报、运营 / 财务经营复盘、周期性监控 | 1080 | 4 图,较高 | Porcelain | 字体联网 |
|
||||
| R05 | 影响力故事 / Impact Story | 项目复盘、产品记录、公益 / 社区案例、影响力叙事、个人成长记录 | 760 | 2 图,低 | Mono | 字体联网 |
|
||||
| R06 | 产品八年年鉴 / Eight-Year Product Almanac | 长周期年鉴、产品 / 公司历史、多年财务或业务趋势、个人长期数据 | 980 | 4 图,高 | Palm | 字体联网 |
|
||||
| R07 | 调研拼贴海报 / Survey Collage Poster | 调研报告海报、用户 / 市场研究、活动 / 展会数据、社媒传播物料 | 980 | 5 图,很高 | Palm | 字体联网 |
|
||||
| R08 | 单位人群一页 / Population One-Pager | 人群 / 用户画像、政策 / 公益简报、市场细分、人口与社会经济数据 | 880 | 2 图,低 | Wire | 字体联网 |
|
||||
| R09 | 数据故事仪表盘 / Data Story Dashboard | dashboard、财务 / 经营驾驶舱、业务总览、竞品 / 市场对比、KPI 快照 | 1080 | 4 图 + KPI,高 | Porcelain | 字体联网 |
|
||||
| R10 | 旅行手记 / Travel Notebook | 旅行数据记录、运动数据记录、个人年度 / 生活数据、轻量项目日志 | 980 | 4 图 + 表,中等 | Palm | 字体联网 |
|
||||
| R11 | 研究简报卡 / Research Brief Card | 研究简报、金融 / 经济快报、社媒卡片、汇报插页、关键指标快照 | 600×1000 | 2 图,定尺 | Mono | Chart.js + ECharts CDN |
|
||||
| R12 | 周报速览 / Weekly Glance | 周报、财务 / 经营快报、运营监控、项目进展、运动或旅行周记 | 1080 | 4 图,高 | Palm | Chart.js + ECharts CDN |
|
||||
|
||||
## 报告类型召回
|
||||
|
||||
- **调研报告 / 研究简报**:R01、R07、R08、R11。
|
||||
- **业务数据报告 / dashboard**:R04、R09、R12。
|
||||
- **财报 / 金融经济报告**:R02、R03、R04、R09、R11、R12。R04 的财务化示例见 `examples/reports/r04-financial-report.zh.html`。
|
||||
- **产品记录 / 项目复盘**:R02、R05、R06、R12。
|
||||
- **个人数据记录**:R03、R05、R06、R10、R12;运动、旅行、年度生活数据都可以使用。
|
||||
- **对外海报 / 社媒分享**:R03、R07、R11。
|
||||
- **模板选择原则**:按内容结构、信息密度、阅读速度和版心选择;不要因为模板名称里写了“旅行”“运营”或“年鉴”,就把它限制在单一行业。
|
||||
- **必须离线运行**:优先 R01–R10,并内联或移除在线字体;R11–R12 需要额外内联图表依赖。
|
||||
- **固定社媒画幅**:R11;内容装不下时换模板,不缩字号或裁掉信息。
|
||||
|
||||
## 模板契约
|
||||
|
||||
- 报告模板是完整页面,不是图表 gallery。复制对应语言的整份 HTML 作为起点。
|
||||
- 保留模板的版心宽度、主网格、章节顺序、主要留白、色彩系统和图表槽位关系。
|
||||
- 允许替换文案、数据、来源、图例和与真实内容冲突的模块;允许删除没有证据支撑的次要模块。
|
||||
- 需要替换页内图型时,从 `catalog.md` 锁定图型并复用对应 gallery 的真实实现,只替换该图表槽位。
|
||||
- 禁止混用两套报告模板,禁止保留演示数据、演示来源或演示结论。
|
||||
@@ -0,0 +1,77 @@
|
||||
// 运行期烟测:打开每个 gallery,确认新增图型真的画出了内容,且控制台无报错。
|
||||
// 用法:node scripts/smoke-new-charts.mjs
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
// playwright 只装在全局,ESM 不认 NODE_PATH,这里显式解析全局路径。
|
||||
const globalRoot = execFileSync('npm', ['root', '-g'], { encoding: 'utf8' }).trim();
|
||||
const playwrightModule = await import(path.join(globalRoot, 'playwright', 'index.js'));
|
||||
const { chromium } = playwrightModule.chromium ? playwrightModule : playwrightModule.default;
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
const groups = {
|
||||
basics: ['treemap', 'histo', 'boxplot', 'stream', 'candle'],
|
||||
lupi: ['matheat', 'calheat', 'beeswarm', 'ridge', 'parallel'],
|
||||
glance: ['violin', 'gheat', 'rankstrip', 'sankey'],
|
||||
};
|
||||
const suffixes = ['gallery', 'porcelain', 'palm', 'wire'];
|
||||
|
||||
const filePath = (group, suffix) =>
|
||||
suffix === 'gallery'
|
||||
? `templates/${group}-gallery.html`
|
||||
: `templates/color/${group}-${suffix}.html`;
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const failures = [];
|
||||
|
||||
for (const [group, ids] of Object.entries(groups)) {
|
||||
for (const suffix of suffixes) {
|
||||
const relative = filePath(group, suffix);
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
||||
const errors = [];
|
||||
page.on('console', message => {
|
||||
if (message.type() === 'error') errors.push(message.text());
|
||||
});
|
||||
page.on('pageerror', error => errors.push(String(error)));
|
||||
|
||||
await page.goto(`file://${path.join(root, relative)}`);
|
||||
// 等布局与 ECharts/SVG 绘制完成
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
for (const id of ids) {
|
||||
// gallery 用 IntersectionObserver 懒渲染,必须滚进视口才会画。
|
||||
await page.evaluate(chartId => {
|
||||
document.getElementById(chartId)?.scrollIntoView({ block: 'center' });
|
||||
}, id);
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
const report = await page.evaluate(chartId => {
|
||||
const node = document.getElementById(chartId);
|
||||
if (!node) return { missing: true };
|
||||
const box = node.getBoundingClientRect();
|
||||
// SVG 手写图看子节点数,ECharts 看是否注入 canvas/svg
|
||||
const drawn = node.querySelectorAll('path,rect,circle,line,polygon,canvas,svg,text').length;
|
||||
return { missing: false, w: Math.round(box.width), h: Math.round(box.height), drawn };
|
||||
}, id);
|
||||
|
||||
if (report.missing) failures.push(`${relative} #${id} 容器不存在`);
|
||||
else if (report.w < 50 || report.h < 30) failures.push(`${relative} #${id} 尺寸异常 ${report.w}×${report.h}`);
|
||||
else if (report.drawn < 3) failures.push(`${relative} #${id} 未绘制内容(子元素 ${report.drawn})`);
|
||||
}
|
||||
|
||||
// 地图依赖联网 GeoJSON,离线时报错属预期,这里只测非地图组
|
||||
for (const error of errors) failures.push(`${relative} 控制台错误:${error}`);
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`烟测失败(${failures.length} 项):`);
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('烟测通过:12 个 gallery 的新增图型均已绘制,无控制台错误。');
|
||||
@@ -0,0 +1,322 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const requiredColorTemplates = [
|
||||
'templates/color/README.md',
|
||||
'templates/color/basics-palm.html',
|
||||
'templates/color/basics-porcelain.html',
|
||||
'templates/color/basics-wire.html',
|
||||
'templates/color/big-circular-palm.html',
|
||||
'templates/color/big-circular-porcelain.html',
|
||||
'templates/color/big-force-palm.html',
|
||||
'templates/color/big-force-porcelain.html',
|
||||
'templates/color/big-threads-palm.html',
|
||||
'templates/color/big-threads-porcelain.html',
|
||||
'templates/color/glance-palm.html',
|
||||
'templates/color/glance-porcelain.html',
|
||||
'templates/color/glance-wire.html',
|
||||
'templates/color/lupi-palm.html',
|
||||
'templates/color/lupi-porcelain.html',
|
||||
'templates/color/lupi-wire.html',
|
||||
'templates/color/maps-palm.html',
|
||||
'templates/color/maps-porcelain.html',
|
||||
'templates/color/maps-wire.html',
|
||||
];
|
||||
const required = [
|
||||
'README.md',
|
||||
'LICENSE',
|
||||
'SKILL.md',
|
||||
'catalog.md',
|
||||
'mono-tokens.js',
|
||||
'color-presets.js',
|
||||
'agents/openai.yaml',
|
||||
'report-catalog.md',
|
||||
'templates/reports/index.html',
|
||||
...requiredColorTemplates,
|
||||
];
|
||||
for (let i = 1; i <= 12; i += 1) {
|
||||
const n = String(i).padStart(2, '0');
|
||||
required.push(`templates/reports/report-${n}.zh.html`);
|
||||
required.push(`templates/reports/report-${n}.en.html`);
|
||||
required.push(`docs/assets/reports/report-${n}.png`);
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
const htmlFiles = [];
|
||||
const ignoredDirectories = new Set(['.git', '.playwright-cli', 'node_modules', 'output']);
|
||||
const paletteSuffixes = ['gallery', 'porcelain', 'palm', 'wire'];
|
||||
|
||||
// 每组 gallery 必须带齐本组图型容器与编号区间;四个色板各一份。
|
||||
const galleryGroups = {
|
||||
basics: {
|
||||
label: 'Basics',
|
||||
range: /F1–F17|17 张/,
|
||||
ids: ['treemap', 'histo', 'boxplot', 'stream', 'candle'],
|
||||
},
|
||||
lupi: {
|
||||
label: 'Lupi',
|
||||
// mono gallery 用「20 张」,色板版用「L1–L20」,两种写法都算已更新。
|
||||
range: /L1–L20|20 张/,
|
||||
ids: ['matheat', 'calheat', 'beeswarm', 'ridge', 'parallel'],
|
||||
},
|
||||
glance: {
|
||||
label: 'Glance',
|
||||
range: /22 张|Glance 22/,
|
||||
ids: ['violin', 'gheat', 'rankstrip', 'sankey'],
|
||||
},
|
||||
maps: {
|
||||
label: 'Maps',
|
||||
ids: ['mapus', 'mapworld'],
|
||||
},
|
||||
};
|
||||
|
||||
function galleryPath(group, suffix) {
|
||||
return suffix === 'gallery'
|
||||
? `templates/${group}-gallery.html`
|
||||
: `templates/color/${group}-${suffix}.html`;
|
||||
}
|
||||
|
||||
function walk(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (ignoredDirectories.has(entry.name)) continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else if (entry.name.endsWith('.html')) htmlFiles.push(full);
|
||||
}
|
||||
}
|
||||
|
||||
function rel(file) {
|
||||
return path.relative(root, file);
|
||||
}
|
||||
|
||||
function lineNumber(source, index) {
|
||||
return source.slice(0, index).split('\n').length;
|
||||
}
|
||||
|
||||
function colorChannels(literal) {
|
||||
if (literal.startsWith('#')) {
|
||||
const hex = literal.slice(1);
|
||||
const full = hex.length === 3
|
||||
? [...hex].map(channel => channel + channel).join('')
|
||||
: hex.slice(0, 6);
|
||||
return [0, 2, 4].map(index => Number.parseInt(full.slice(index, index + 2), 16)).join(',');
|
||||
}
|
||||
|
||||
const channels = literal.match(/\d+/g);
|
||||
return channels?.slice(0, 3).join(',') ?? null;
|
||||
}
|
||||
|
||||
function collectPresetChannels(value, channels = new Set()) {
|
||||
if (typeof value === 'string') {
|
||||
const literalPattern = /#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)/g;
|
||||
for (const match of value.matchAll(literalPattern)) {
|
||||
const parsed = colorChannels(match[0]);
|
||||
if (parsed) channels.add(parsed);
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const item of value) collectPresetChannels(item, channels);
|
||||
} else if (value && typeof value === 'object') {
|
||||
for (const item of Object.values(value)) collectPresetChannels(item, channels);
|
||||
}
|
||||
return channels;
|
||||
}
|
||||
|
||||
for (const file of required) {
|
||||
if (!fs.existsSync(path.join(root, file))) failures.push(`缺少发布文件:${file}`);
|
||||
}
|
||||
|
||||
for (const [group, spec] of Object.entries(galleryGroups)) {
|
||||
for (const suffix of paletteSuffixes) {
|
||||
const file = galleryPath(group, suffix);
|
||||
const full = path.join(root, file);
|
||||
if (!fs.existsSync(full)) {
|
||||
failures.push(`缺少 gallery 模板:${file}`);
|
||||
continue;
|
||||
}
|
||||
const source = fs.readFileSync(full, 'utf8');
|
||||
for (const id of spec.ids) {
|
||||
if (!source.includes(`id="${id}"`)) failures.push(`${file} 缺少图型容器 id="${id}"`);
|
||||
}
|
||||
if (spec.range && !spec.range.test(source)) {
|
||||
failures.push(`${file} 的 ${spec.label} 编号未更新(应匹配 ${spec.range})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const catalogSource = fs.readFileSync(path.join(root, 'catalog.md'), 'utf8');
|
||||
const catalogRows = [
|
||||
'| F13 | Nested Treemap |',
|
||||
'| F14 | Rung Histogram |',
|
||||
'| F15 | Tick Box |',
|
||||
'| F16 | Stream Ribbon |',
|
||||
'| F17 | Candlestick |',
|
||||
'| L16 | Matrix Heat |',
|
||||
'| L17 | Calendar Heat |',
|
||||
'| L18 | Beeswarm |',
|
||||
'| L19 | Ridgeline |',
|
||||
'| L20 | Parallel Coordinates |',
|
||||
'| G19 | Violin |',
|
||||
'| G20 | Matrix Heat (Glance) |',
|
||||
'| G21 | Rank Strip |',
|
||||
'| G22 | Aggregate Sankey |',
|
||||
'| M1 | US Choropleth |',
|
||||
'| M2 | World Choropleth |',
|
||||
];
|
||||
for (const row of catalogRows) {
|
||||
if (!catalogSource.includes(row)) failures.push(`catalog.md 缺少条目 ${row.replaceAll('|', '').trim()}`);
|
||||
}
|
||||
|
||||
const skillSource = fs.readFileSync(path.join(root, 'SKILL.md'), 'utf8');
|
||||
// 主力/后备分档是硬约束,别在后续改动里被顺手删掉。
|
||||
if (!skillSource.includes('L1–L15 与 F1–F13')) {
|
||||
failures.push('SKILL.md 缺少主力(L1–L15 / F1–F13)优先规则');
|
||||
}
|
||||
for (const backup of ['F17 Candlestick', 'F15 Tick Box', 'L20 Parallel Coordinates', 'L17 Calendar Heat', 'F16 Stream Ribbon']) {
|
||||
if (!skillSource.includes(backup)) failures.push(`SKILL.md 缺少后备图例外说明:${backup}`);
|
||||
}
|
||||
if (!catalogSource.includes('主力与后备')) failures.push('catalog.md 缺少主力/后备说明');
|
||||
for (const role of ['`BG`', '`TXT`', '`MUT`', '`GRID`', '`DATA`']) {
|
||||
if (!skillSource.includes(role)) failures.push(`SKILL.md 的 custom 色板规则缺少角色 ${role}`);
|
||||
}
|
||||
|
||||
const reportCatalogSource = fs.readFileSync(path.join(root, 'report-catalog.md'), 'utf8');
|
||||
for (let i = 1; i <= 12; i += 1) {
|
||||
const n = String(i).padStart(2, '0');
|
||||
if (!reportCatalogSource.includes(`| R${n} |`)) failures.push(`report-catalog.md 缺少 R${n}`);
|
||||
for (const language of ['zh', 'en']) {
|
||||
const file = path.join(root, `templates/reports/report-${n}.${language}.html`);
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
if (!source.includes('<!doctype html>')) failures.push(`${rel(file)} 不是完整 HTML 文档`);
|
||||
if (!source.match(/<title>[\s\S]+<\/title>/i)) failures.push(`${rel(file)} 缺少 title`);
|
||||
}
|
||||
}
|
||||
|
||||
const presetChannels = new Map();
|
||||
const colorPresetsPath = path.join(root, 'color-presets.js');
|
||||
if (fs.existsSync(colorPresetsPath)) {
|
||||
try {
|
||||
const context = vm.createContext({});
|
||||
new vm.Script(fs.readFileSync(colorPresetsPath, 'utf8'), {
|
||||
filename: 'color-presets.js',
|
||||
}).runInContext(context);
|
||||
|
||||
const presets = context.PRESETS;
|
||||
const expected = [
|
||||
['porcelain', 'PORCELAIN'],
|
||||
['palm', 'PALM'],
|
||||
['wire', 'WIRE'],
|
||||
];
|
||||
if (!presets || typeof presets.get !== 'function') {
|
||||
failures.push('color-presets.js 未正确暴露 PRESETS.get()');
|
||||
} else {
|
||||
for (const [name, key] of expected) {
|
||||
const preset = presets[key];
|
||||
if (!preset || presets.get(name) !== preset) {
|
||||
failures.push(`color-presets.js 缺少可用预设:${name}`);
|
||||
continue;
|
||||
}
|
||||
presetChannels.set(name, collectPresetChannels(preset));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
walk(root);
|
||||
|
||||
for (const file of htmlFiles) {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
const scriptPattern = /<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/gi;
|
||||
let match;
|
||||
let scriptIndex = 0;
|
||||
|
||||
while ((match = scriptPattern.exec(source))) {
|
||||
scriptIndex += 1;
|
||||
if (!match[1].trim()) continue;
|
||||
try {
|
||||
new vm.Script(match[1], { filename: `${rel(file)}#script-${scriptIndex}` });
|
||||
} catch (error) {
|
||||
failures.push(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const seenIds = new Map();
|
||||
const idPattern = /\sid=(["'])([^"']+)\1/g;
|
||||
while ((match = idPattern.exec(source))) {
|
||||
const id = match[2];
|
||||
const line = lineNumber(source, match.index);
|
||||
if (seenIds.has(id)) {
|
||||
failures.push(`${rel(file)}:${line} 重复 id="${id}"(首次出现于第 ${seenIds.get(id)} 行)`);
|
||||
} else {
|
||||
seenIds.set(id, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const textFiles = [];
|
||||
function collectText(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (ignoredDirectories.has(entry.name) || entry.name === 'LICENSE') continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) collectText(full);
|
||||
else if (/\.(?:html|js|mjs)$/.test(entry.name)) textFiles.push(full);
|
||||
}
|
||||
}
|
||||
collectText(root);
|
||||
|
||||
for (const file of textFiles) {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
const relative = rel(file);
|
||||
const executableSource = relative === 'scripts/validate.mjs'
|
||||
? ''
|
||||
: source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
|
||||
for (const match of executableSource.matchAll(/Math\.random\s*\(/g)) {
|
||||
failures.push(`${rel(file)} 检测到 Math.random(),请改用确定性 rnd()`);
|
||||
}
|
||||
|
||||
const colorTemplate = relative.match(/^templates\/color\/.+-(porcelain|palm|wire)\.html$/);
|
||||
if (colorTemplate) {
|
||||
const allowed = presetChannels.get(colorTemplate[1]);
|
||||
const literalPattern = /#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)/g;
|
||||
for (const match of source.matchAll(literalPattern)) {
|
||||
const channels = colorChannels(match[0]);
|
||||
const transparentHitTarget = channels === '0,0,0';
|
||||
if (allowed && channels && !allowed.has(channels) && !transparentHitTarget) {
|
||||
failures.push(`${relative}:${lineNumber(source, match.index)} 颜色 ${match[0]} 不属于 ${colorTemplate[1]} 预设`);
|
||||
}
|
||||
}
|
||||
} else if (relative !== 'color-presets.js' && !relative.startsWith('templates/reports/')) {
|
||||
for (const match of source.matchAll(/#[0-9a-fA-F]{6}\b/g)) {
|
||||
const hex = match[0];
|
||||
const channels = [
|
||||
Number.parseInt(hex.slice(1, 3), 16),
|
||||
Number.parseInt(hex.slice(3, 5), 16),
|
||||
Number.parseInt(hex.slice(5, 7), 16),
|
||||
];
|
||||
if (Math.max(...channels) - Math.min(...channels) > 18) {
|
||||
failures.push(`${relative}:${lineNumber(source, match.index)} 检测到明显彩色值 ${hex}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
new vm.Script(fs.readFileSync(path.join(root, 'mono-tokens.js'), 'utf8'), {
|
||||
filename: 'mono-tokens.js',
|
||||
});
|
||||
} catch (error) {
|
||||
failures.push(error.message);
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`检查失败(${failures.length} 项):`);
|
||||
for (const failure of failures) console.error(`- ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`检查通过:${htmlFiles.length} 个 HTML 文件,${textFiles.length} 个文本文件。`);
|
||||
@@ -0,0 +1,943 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Lieflat Charts — Basics Gallery · 17 张基础型参考实现</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@6/dist/echarts.min.js"></script>
|
||||
<style>
|
||||
:root{--bg:#F0EFEB;--ink:#1C1C1A;--muted:#8F8E88;--faint:#C6C5BF;--grid:#DEDDD6}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);font-family:'Inter',sans-serif;color:var(--ink);padding:40px;-webkit-font-smoothing:antialiased}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:22px;max-width:1100px;margin:0 auto}
|
||||
.card{background:var(--bg);border-radius:24px;padding:28px 28px 20px}
|
||||
.card.wide{grid-column:1/-1}
|
||||
h2{font-weight:700;font-size:16.5px;letter-spacing:-.02em;margin-bottom:3px}
|
||||
.sub{font-size:11.5px;color:var(--muted);margin-bottom:14px}
|
||||
.src{font-size:9.5px;color:var(--faint);margin-top:10px;letter-spacing:.08em;font-weight:500}
|
||||
.badge{display:inline-block;font-size:9px;font-weight:700;letter-spacing:.08em;
|
||||
padding:3px 10px;border-radius:99px;margin-bottom:10px;border:1px dashed #B0AFA9;color:#8F8E88}
|
||||
svg{width:100%;max-height:330px;display:block;margin:0 auto}
|
||||
.chartbox{width:100%;height:360px}
|
||||
svg text{font-family:'Inter',sans-serif}
|
||||
.pop{transform-box:fill-box;transform-origin:center;animation:pop .5s cubic-bezier(.2,.7,.3,1.3) both}
|
||||
@keyframes pop{from{transform:scale(0)}to{transform:none}}
|
||||
.fade{animation:fade .9s ease both}
|
||||
@keyframes fade{from{opacity:0}}
|
||||
.draw{stroke-dasharray:1;stroke-dashoffset:1;animation:draw 1s cubic-bezier(.4,0,.2,1) both}
|
||||
@keyframes draw{to{stroke-dashoffset:0}}
|
||||
|
||||
.pagehead{max-width:1100px;margin:0 auto 26px;padding:0 4px}
|
||||
.pagehead h1{font-size:22px;letter-spacing:-.02em;font-weight:800}
|
||||
.pagehead p{font-size:12px;color:#8F8E88;margin-top:5px;line-height:1.7}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="pagehead">
|
||||
<h1>基础型组(F1–F17)· Lupi 语法 × 基础图型剪影</h1>
|
||||
<p>柱 / 折线 / 面积 / 环形 / 横条 / 分组 / 堆叠 / 散点 / 瀑布 / 热力 / 进度 / 哑铃 / 矩形树图。远看认得出图型剪影,近看每个单位可数(1 档 = $1k · 1 tick = 1 人 / 1%)。为稀疏聚合数据而设:数据不用多,密度来自单位分解。代码检索方式同 lupi-gallery:按 <code>// ════ 图型名 ════</code> 注释块。</p>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
|
||||
<!-- B1 · rung bars -->
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 柱状</span>
|
||||
<h2>Revenue by plan, rung by rung</h2>
|
||||
<div class="sub">one rung = one $k of MRR · the bar is a ladder you can count</div>
|
||||
<svg id="rungs" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">RUNG BARS · MONO-BASIC · BILLING</div>
|
||||
</div>
|
||||
|
||||
<!-- B2 · hairline line -->
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 折线</span>
|
||||
<h2>Thirty days of sign-ups</h2>
|
||||
<div class="sub">one dot = one day · hollow = weekend · the barcode floor keeps the calendar honest</div>
|
||||
<svg id="dayline" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">HAIRLINE LINE · MONO-BASIC · GROWTH</div>
|
||||
</div>
|
||||
|
||||
<!-- B3 · hairline area -->
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 面积</span>
|
||||
<h2>Concurrent users, filled with days</h2>
|
||||
<div class="sub">one hairline = one day, floor to peak · the area is made of days, not paint</div>
|
||||
<svg id="hairarea" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">HAIRLINE AREA · MONO-BASIC · LIVE OPS</div>
|
||||
</div>
|
||||
|
||||
<!-- B4 · tick donut -->
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 环形</span>
|
||||
<h2>Where the traffic comes from</h2>
|
||||
<div class="sub">one tick = one percent · four sources shade the dial · read it like a clock</div>
|
||||
<svg id="tickdonut" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">TICK DONUT · MONO-BASIC · ACQUISITION</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- C1 · tick rows (hbar) -->
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 横向条形</span>
|
||||
<h2>Six teams, shipped and counted</h2>
|
||||
<div class="sub">one tick = one release this quarter · the row is a queue, not a bar</div>
|
||||
<svg id="tickrows" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">TICK ROWS · MONO-BASIC · RELEASE LOG</div>
|
||||
</div>
|
||||
|
||||
<!-- C2 · paired rungs (grouped bar) -->
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 分组柱</span>
|
||||
<h2>This year against last, plan by plan</h2>
|
||||
<div class="sub">two ladders per plan · solid rungs = 2026 · faint rungs = 2025 · one rung = $1k</div>
|
||||
<svg id="pairrungs" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">PAIRED RUNGS · MONO-BASIC · BILLING YoY</div>
|
||||
</div>
|
||||
|
||||
<!-- C3 · stacked rungs -->
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 堆叠柱</span>
|
||||
<h2>Where each region's revenue sits</h2>
|
||||
<div class="sub">one rung = $1k · three products stack up the ladder, darkest carries the most</div>
|
||||
<svg id="stackrungs" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">STACKED RUNGS · MONO-BASIC · REVENUE MIX</div>
|
||||
</div>
|
||||
|
||||
<!-- C4 · plumb scatter -->
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 散点</span>
|
||||
<h2>Price against satisfaction, twelve products</h2>
|
||||
<div class="sub">every dot hangs a plumb line to the floor · read x by the line, y by the height</div>
|
||||
<svg id="plumb" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">PLUMB SCATTER · MONO-BASIC · PRODUCT PANEL</div>
|
||||
</div>
|
||||
|
||||
<!-- C5 · rung waterfall -->
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 瀑布</span>
|
||||
<h2>From gross to net, step by step</h2>
|
||||
<div class="sub">one rung = $1k · solid rungs add, hollow rungs take away · the ladder walks down</div>
|
||||
<svg id="fall" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">RUNG WATERFALL · MONO-BASIC · P&L</div>
|
||||
</div>
|
||||
|
||||
<!-- C6 · dot heat -->
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 热力</span>
|
||||
<h2>When support gets loud</h2>
|
||||
<div class="sub">7 days × 12 hours · dot area = tickets · biggest cell labeled · silence stays visible</div>
|
||||
<svg id="dotheat" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">DOT HEAT · MONO-BASIC · SUPPORT DESK</div>
|
||||
</div>
|
||||
|
||||
<!-- C7 · tick gauge -->
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 进度</span>
|
||||
<h2>How far to the quarter's goal</h2>
|
||||
<div class="sub">one tick = one percent of target · inked = earned · the dial reads left to right</div>
|
||||
<svg id="gauge" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">TICK GAUGE · MONO-BASIC · QUARTERLY TARGET</div>
|
||||
</div>
|
||||
|
||||
<!-- C8 · dumbbell queue -->
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 哑铃对比</span>
|
||||
<h2>Onboarding, before and after the redesign</h2>
|
||||
<div class="sub">hollow dot = before · solid dot = after · beads on the track = minutes saved, countable</div>
|
||||
<svg id="dumbbell" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">DUMBBELL QUEUE · MONO-BASIC · UX TIMING</div>
|
||||
</div>
|
||||
|
||||
<!-- C9 · nested treemap -->
|
||||
<div class="card wide">
|
||||
<span class="badge">LUPI 基础型 · 矩形树图</span>
|
||||
<h2>Where the work went</h2>
|
||||
<div class="sub">1,280 shipped hours · area = effort · parent bands preserve the product hierarchy</div>
|
||||
<div id="treemap" class="chartbox" role="img" aria-label="Treemap of shipped hours nested by product family"></div>
|
||||
<div class="src">NESTED TREEMAP · MONO-BASIC · WORK PORTFOLIO</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- F14 · rung histogram -->
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 直方图</span>
|
||||
<h2>Most tickets resolve within six hours</h2>
|
||||
<div class="sub">one rung = one ticket in a hundred · bins of two hours · the ladder shows the pile</div>
|
||||
<svg id="histo" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">RUNG HISTOGRAM · MONO-BASIC · SUPPORT DESK</div>
|
||||
</div>
|
||||
|
||||
<!-- F15 · tick box -->
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 箱线图</span>
|
||||
<h2>Reply times, boxed by plan</h2>
|
||||
<div class="sub">box = the middle half of tickets · paper tick = median · hollow dot = an outlier nobody enjoyed</div>
|
||||
<svg id="boxplot" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">TICK BOX · MONO-BASIC · SUPPORT DESK</div>
|
||||
</div>
|
||||
|
||||
<!-- F16 · stream ribbon -->
|
||||
<div class="card wide">
|
||||
<span class="badge">LUPI 基础型 · STREAMGRAPH</span>
|
||||
<h2>Three products trade the same river</h2>
|
||||
<div class="sub">48 weeks of usage share · band width = weekly active accounts · the river swells where the total grows</div>
|
||||
<svg id="stream" viewBox="0 0 840 300" preserveAspectRatio="xMidYMid meet" style="max-height:300px"></svg>
|
||||
<div class="src">STREAM RIBBON · MONO-BASIC · PRODUCT ANALYTICS</div>
|
||||
</div>
|
||||
|
||||
<!-- F17 · candlestick -->
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · K 线</span>
|
||||
<h2>Six weeks of the token, candle by candle</h2>
|
||||
<div class="sub">body = open to close · wick = the day's full reach · ink = closed down · hollow = closed up</div>
|
||||
<svg id="candle" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">CANDLESTICK · MONO-BASIC · MARKET DATA</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
// ── shared Mono tokens(与 lupi-gallery 同源) ──
|
||||
const INK='#1C1C1A',PAPER='#F0EFEB',MUTED='#8F8E88',GRID='#DEDDD6';
|
||||
const NS='http://www.w3.org/2000/svg';
|
||||
const el=(p,t,a)=>{const n=document.createElementNS(NS,t);for(const k in a)n.setAttribute(k,a[k]);p.appendChild(n);return n};
|
||||
const txt=(p,a,s)=>{const n=el(p,'text',a);n.textContent=s;return n};
|
||||
const tip=(n,s)=>{const t=document.createElementNS(NS,'title');t.textContent=s;n.appendChild(t)};
|
||||
const rnd=(i,k)=>Math.abs(((i*73856093)^(k*19349663))%1000)/1000;
|
||||
const D2R=Math.PI/180;
|
||||
const pol=(cx,cy,r,deg)=>[cx+r*Math.cos(deg*D2R),cy+r*Math.sin(deg*D2R)];
|
||||
const obsReveal=(id,fn)=>{
|
||||
const n=document.getElementById(id);
|
||||
const go=()=>{n.innerHTML='';fn(n)};
|
||||
const io=new IntersectionObserver(es=>{if(es[0].isIntersecting){go();io.disconnect()}},{threshold:.3});
|
||||
io.observe(n);
|
||||
n.style.cursor='pointer';
|
||||
n.addEventListener('click',go);
|
||||
};
|
||||
|
||||
// ════ B1 · rung bars ════
|
||||
// 柱状图的 Lupi 化:柱身 = 一格格横档,1 档 = 1 个诚实单位。
|
||||
// 远看是柱状图剪影,近看每一档都数得出来。档长、透明度 rnd 抖动。
|
||||
(()=>{
|
||||
const D=[['FREE',38],['STARTER',27],['PRO',22],['TEAM',16],['SCALE',11],['ENT',7]];
|
||||
obsReveal('rungs',s=>{
|
||||
const x0=i=>56+i*56,base=266,step=5.6,HW=14;
|
||||
D.forEach(([name,v],i)=>{
|
||||
const x=x0(i);
|
||||
for(let k=0;k<v;k++){
|
||||
const y=base-k*step,w=HW-1.5+rnd(k+1,i+2)*3;
|
||||
el(s,'line',{x1:x-w,y1:y,x2:x+w,y2:y,stroke:INK,'stroke-width':1,
|
||||
opacity:.5+rnd(k+2,i+4)*.5,class:'fade',style:`animation-delay:${i*.08+k*.012}s`});
|
||||
if(k%5===4)el(s,'circle',{cx:x+HW+4.5,cy:y,r:.8,fill:'#C6C5BF',
|
||||
class:'fade',style:`animation-delay:${i*.08+k*.012}s`});
|
||||
}
|
||||
const topY=base-(v-1)*step;
|
||||
const num=txt(s,{x,y:topY-10,'font-size':11,'font-weight':800,fill:INK,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${.4+i*.08}s`},v);
|
||||
tip(num,`${name} — $${v}k MRR`);
|
||||
txt(s,{x,y:base+18,'font-size':7.5,'font-weight':700,fill:MUTED,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.08}s`},name);
|
||||
});
|
||||
el(s,'line',{x1:28,y1:base+4,x2:372,y2:base+4,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:.9s'},
|
||||
'ONE RUNG = $1K · DOT MARKS EVERY FIFTH');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ B2 · hairline line ════
|
||||
// 折线图的 Lupi 化:barcode 的日历地板(每天一根发丝,无论有没有事)+
|
||||
// 发丝折线 + 逐日圆点(周末空心)。峰值两个点放大标数。
|
||||
(()=>{
|
||||
const N=30;
|
||||
const val=d=>46+22*Math.sin(d/4.6)+14*Math.sin(d/2.1)+rnd(d+1,5)*12;
|
||||
obsReveal('dayline',s=>{
|
||||
const x=d=>30+d*11.7,base=262,map=v=>base-v*2.15;
|
||||
// calendar floor
|
||||
for(let d=0;d<N;d++)
|
||||
el(s,'line',{x1:x(d),y1:base,x2:x(d),y2:base-7,stroke:'#CFCEC7','stroke-width':.6,
|
||||
class:'fade',style:`animation-delay:${d*.008}s`});
|
||||
el(s,'line',{x1:24,y1:base,x2:376,y2:base,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
const vs=Array.from({length:N},(_,d)=>val(d));
|
||||
// top-2 peaks, apart
|
||||
const top=[];
|
||||
for(const d of [...vs.keys()].sort((a,b)=>vs[b]-vs[a])){
|
||||
if(top.every(t=>Math.abs(t-d)>=5))top.push(d);
|
||||
if(top.length===2)break;
|
||||
}
|
||||
// hairline path
|
||||
const pts=vs.map((v,d)=>`${x(d)} ${map(v)}`).join(' L ');
|
||||
el(s,'path',{d:'M'+pts,fill:'none',stroke:INK,'stroke-width':1,pathLength:1,
|
||||
class:'draw',style:'animation-duration:1.2s'});
|
||||
vs.forEach((v,d)=>{
|
||||
const weekend=d%7===5||d%7===6,big=top.includes(d);
|
||||
const dot=el(s,'circle',{cx:x(d),cy:map(v),r:big?4.2:2.1,
|
||||
fill:weekend?PAPER:INK,stroke:INK,'stroke-width':weekend?1:0,
|
||||
class:'pop',style:`animation-delay:${.2+d*.03}s`});
|
||||
tip(dot,`Day ${d+1} — ${Math.round(v)} sign-ups`);
|
||||
if(big)txt(s,{x:x(d),y:map(v)-11,'font-size':9.5,'font-weight':800,fill:INK,'text-anchor':'middle',
|
||||
style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:${1+d*.01}s`,
|
||||
class:'fade'},Math.round(v));
|
||||
});
|
||||
[[0,'JUN 1'],[14,'JUN 15'],[29,'JUN 30']].forEach(([d,m])=>
|
||||
txt(s,{x:x(d),y:base+18,'font-size':7.5,'font-weight':600,fill:MUTED,'text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade'},m));
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
|
||||
'ONE DOT = ONE DAY · HOLLOW = WEEKEND');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ B3 · hairline area ════
|
||||
// 面积图的 Lupi 化:填充不是色块,是一天一根发丝立到自己的峰值。
|
||||
// 面积由日子组成。顶边一根细线收轮廓,峰值日加点标数。
|
||||
(()=>{
|
||||
const N=45;
|
||||
const val=d=>34+26*Math.sin(d/7.2)+12*Math.sin(d/2.8)+rnd(d+1,3)*16;
|
||||
obsReveal('hairarea',s=>{
|
||||
const x=d=>28+d*7.75,base=262,map=v=>base-v*2.35;
|
||||
el(s,'line',{x1:22,y1:base,x2:378,y2:base,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
const vs=Array.from({length:N},(_,d)=>val(d));
|
||||
const peak=vs.indexOf(Math.max(...vs));
|
||||
vs.forEach((v,d)=>{
|
||||
el(s,'line',{x1:x(d),y1:base,x2:x(d),y2:map(v),
|
||||
stroke:d===peak?INK:'#8F8E88','stroke-width':d===peak?1.1:.55,
|
||||
opacity:d===peak?1:.5+rnd(d+1,7)*.45,
|
||||
class:'fade',style:`animation-delay:${d*.014}s`});
|
||||
});
|
||||
const pts=vs.map((v,d)=>`${x(d)} ${map(v)}`).join(' L ');
|
||||
el(s,'path',{d:'M'+pts,fill:'none',stroke:INK,'stroke-width':1.2,pathLength:1,
|
||||
class:'draw',style:'animation-delay:.4s;animation-duration:1.2s'});
|
||||
const pd=el(s,'circle',{cx:x(peak),cy:map(vs[peak]),r:4.2,fill:INK,
|
||||
class:'pop',style:'animation-delay:1.2s'});
|
||||
tip(pd,`Day ${peak+1} — ${Math.round(vs[peak])}k peak`);
|
||||
txt(s,{x:x(peak),y:map(vs[peak])-11,'font-size':9.5,'font-weight':800,fill:INK,'text-anchor':'middle',
|
||||
style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:1.3s`,
|
||||
class:'fade'},Math.round(vs[peak])+'k');
|
||||
[[0,'MAY'],[22,'JUN'],[44,'JUL']].forEach(([d,m])=>
|
||||
txt(s,{x:x(d),y:base+18,'font-size':7.5,'font-weight':600,fill:MUTED,'text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade'},m));
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.3s'},
|
||||
'ONE HAIRLINE = ONE DAY, FLOOR TO PEAK');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ B4 · tick donut ════
|
||||
// 环形图的 Lupi 化:ballot tally 的百人队列绕成整圈表盘,1 tick = 1%,
|
||||
// 四段来源用灰阶 ladder 分段,段间留一格呼吸。中心只放总数和单位说明。
|
||||
(()=>{
|
||||
const D=[['ORGANIC',37,INK],['PAID',28,'#55554F'],['REFERRAL',21,'#8F8E88'],['SOCIAL',14,'#B0AFA9']];
|
||||
obsReveal('tickdonut',s=>{
|
||||
const cx=200,cy=148,R0=64;
|
||||
let k0=0;
|
||||
D.forEach(([name,v,shade],si)=>{
|
||||
for(let k=0;k<v;k++){
|
||||
const idx=k0+k,a=idx*3.6-90;
|
||||
const len=10+rnd(idx+1,si+2)*6;
|
||||
const [x1,y1]=pol(cx,cy,R0,a),[x2,y2]=pol(cx,cy,R0+len,a);
|
||||
el(s,'line',{x1,y1,x2,y2,stroke:shade,'stroke-width':1,
|
||||
class:'fade',style:`animation-delay:${idx*.012}s`});
|
||||
if(idx%10===0){
|
||||
const [dx,dy]=pol(cx,cy,R0-5,a);
|
||||
el(s,'circle',{cx:dx,cy:dy,r:.8,fill:'#C6C5BF',
|
||||
class:'fade',style:`animation-delay:${idx*.012}s`});
|
||||
}
|
||||
}
|
||||
// segment label at mid-angle, outside, tied by a dotted hairline
|
||||
const mid=(k0+v/2)*3.6-90,[lx,ly]=pol(cx,cy,R0+38,mid),[gx,gy]=pol(cx,cy,R0+20,mid);
|
||||
el(s,'line',{x1:gx,y1:gy,x2:lx,y2:ly,stroke:'#C6C5BF','stroke-width':.7,
|
||||
'stroke-dasharray':'1 3',class:'fade',style:`animation-delay:${.6+si*.1}s`});
|
||||
const anchor=Math.cos(mid*D2R)>0.3?'start':Math.cos(mid*D2R)<-0.3?'end':'middle';
|
||||
const lab=txt(s,{x:lx,y:ly+3,'font-size':8,'font-weight':800,fill:shade,'text-anchor':anchor,
|
||||
'letter-spacing':'.06em',style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:${.65+si*.1}s`,
|
||||
class:'fade'},`${name} · ${v}`);
|
||||
tip(lab,`${name} — ${v}% of traffic`);
|
||||
k0+=v;
|
||||
});
|
||||
txt(s,{x:cx,y:cy-2,'font-size':22,'font-weight':800,fill:INK,'text-anchor':'middle',
|
||||
class:'fade',style:'animation-delay:.9s'},'100');
|
||||
txt(s,{x:cx,y:cy+14,'font-size':7,'font-weight':600,fill:MUTED,'text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade',style:'animation-delay:.9s'},'TICKS · ONE = 1%');
|
||||
txt(s,{x:200,y:296,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
|
||||
'TWELVE O’CLOCK IS ZERO · DOT MARKS EVERY TENTH · READS CLOCKWISE');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C1 · tick rows ════
|
||||
// 横向条形:一行 = 一支队伍的发布队列,1 tick = 1 次发布。
|
||||
// tick 高度/透明度抖动,每 5 根一个点标,行尾大数。
|
||||
(()=>{
|
||||
const D=[['PLATFORM',34],['GROWTH',28],['MOBILE',22],['INFRA',17],['ML',11],['DESIGN',8]];
|
||||
obsReveal('tickrows',s=>{
|
||||
const y0=i=>52+i*44,X0=104,PX=6.9;
|
||||
D.forEach(([name,v],i)=>{
|
||||
const y=y0(i);
|
||||
txt(s,{x:94,y:y+3,'font-size':8,'font-weight':700,fill:'#6A6963','text-anchor':'end',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.08}s`},name);
|
||||
el(s,'line',{x1:X0,y1:y+9,x2:X0+34*PX,y2:y+9,stroke:GRID,'stroke-width':.6,
|
||||
class:'fade',style:`animation-delay:${i*.08}s`});
|
||||
for(let k=0;k<v;k++){
|
||||
const x=X0+k*PX+PX/2,h=9+rnd(k+1,i+2)*6;
|
||||
el(s,'line',{x1:x,y1:y+9,x2:x,y2:y+9-h,stroke:INK,'stroke-width':.9,
|
||||
opacity:.55+rnd(k+3,i+5)*.45,class:'fade',style:`animation-delay:${i*.08+k*.012}s`});
|
||||
if(k%5===4)el(s,'circle',{cx:x,cy:y+13,r:.8,fill:'#C6C5BF',
|
||||
class:'fade',style:`animation-delay:${i*.08+k*.012}s`});
|
||||
}
|
||||
const lab=txt(s,{x:X0+v*PX+10,y:y+4,'font-size':11,'font-weight':800,fill:INK,
|
||||
class:'fade',style:`animation-delay:${.4+i*.08}s`},v);
|
||||
tip(lab,`${name} — ${v} releases`);
|
||||
});
|
||||
txt(s,{x:200,y:308,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:.9s'},
|
||||
'ONE TICK = ONE RELEASE · DOT MARKS EVERY FIFTH');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C2 · paired rungs ════
|
||||
// 分组柱:每类两把梯子并肩,今年实档、去年淡档。差值一眼可数。
|
||||
(()=>{
|
||||
const D=[['FREE',31,38],['STARTER',22,27],['PRO',16,22],['TEAM',13,16],['ENT',6,9]];
|
||||
obsReveal('pairrungs',s=>{
|
||||
const x0=i=>64+i*66,base=258,step=5.4,HW=10;
|
||||
D.forEach(([name,was,now],i)=>{
|
||||
const xa=x0(i)-13,xb=x0(i)+13;
|
||||
for(let k=0;k<was;k++){
|
||||
const y=base-k*step,w=HW-1.2+rnd(k+1,i+2)*2.4;
|
||||
el(s,'line',{x1:xa-w,y1:y,x2:xa+w,y2:y,stroke:'#B0AFA9','stroke-width':1,
|
||||
opacity:.5+rnd(k+2,i+3)*.4,class:'fade',style:`animation-delay:${i*.08+k*.01}s`});
|
||||
}
|
||||
for(let k=0;k<now;k++){
|
||||
const y=base-k*step,w=HW-1.2+rnd(k+1,i+7)*2.4;
|
||||
el(s,'line',{x1:xb-w,y1:y,x2:xb+w,y2:y,stroke:INK,'stroke-width':1,
|
||||
opacity:.6+rnd(k+2,i+8)*.4,class:'fade',style:`animation-delay:${.15+i*.08+k*.01}s`});
|
||||
}
|
||||
const topB=base-(now-1)*step;
|
||||
const num=txt(s,{x:xb,y:topB-9,'font-size':10.5,'font-weight':800,fill:INK,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${.5+i*.08}s`},now);
|
||||
tip(num,`${name} — $${was}k → $${now}k`);
|
||||
txt(s,{x:xa,y:base-(was-1)*step-9,'font-size':8.5,'font-weight':700,fill:'#B0AFA9','text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${.5+i*.08}s`},was);
|
||||
txt(s,{x:x0(i),y:base+18,'font-size':7.5,'font-weight':700,fill:MUTED,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.08}s`},name);
|
||||
});
|
||||
el(s,'line',{x1:30,y1:base+4,x2:370,y2:base+4,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1s'},
|
||||
'FAINT = 2025 · INK = 2026 · ONE RUNG = $1K');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C3 · stacked rungs ════
|
||||
// 堆叠柱:同一把梯子分三段灰阶,段间空一格呼吸,段值贴段中。
|
||||
(()=>{
|
||||
const D=[['NA',[18,11,7]],['EU',[14,9,5]],['APAC',[9,7,6]],['LATAM',[5,4,2]]];
|
||||
const SHADE=[INK,'#8F8E88','#C0BFB8'];
|
||||
const SEG=['CORE','ADD-ONS','SERVICES'];
|
||||
obsReveal('stackrungs',s=>{
|
||||
const x0=i=>72+i*76,base=262,step=5.2,HW=13;
|
||||
D.forEach(([name,segs],i)=>{
|
||||
const x=x0(i);let k0=0;
|
||||
segs.forEach((v,si)=>{
|
||||
for(let k=0;k<v;k++){
|
||||
const y=base-(k0+k+si)*step,w=HW-1.4+rnd(k+1,i*3+si+2)*2.8;
|
||||
el(s,'line',{x1:x-w,y1:y,x2:x+w,y2:y,stroke:SHADE[si],'stroke-width':1,
|
||||
opacity:.6+rnd(k+2,i+si+4)*.4,class:'fade',
|
||||
style:`animation-delay:${i*.09+(k0+k)*.012}s`});
|
||||
}
|
||||
const midY=base-(k0+v/2+si)*step;
|
||||
const lab=txt(s,{x:x+HW+7,y:midY+2.5,'font-size':8,'font-weight':800,fill:SHADE[si]===`#C0BFB8`?'#8F8E88':SHADE[si],
|
||||
class:'fade',style:`animation-delay:${.5+i*.09+si*.06}s`},v);
|
||||
tip(lab,`${name} ${SEG[si]} — $${v}k`);
|
||||
k0+=v;
|
||||
});
|
||||
const total=segs[0]+segs[1]+segs[2];
|
||||
txt(s,{x,y:base-(k0+2)*step-8,'font-size':10.5,'font-weight':800,fill:INK,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${.6+i*.09}s`},total);
|
||||
txt(s,{x,y:base+18,'font-size':7.5,'font-weight':700,fill:MUTED,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.09}s`},name);
|
||||
});
|
||||
el(s,'line',{x1:36,y1:base+4,x2:364,y2:base+4,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
|
||||
'DARKEST = CORE · MID = ADD-ONS · PALE = SERVICES · ONE RUNG = $1K');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C4 · plumb scatter ════
|
||||
// 散点:每个点垂一根发丝铅垂线到地板,x 读线脚、y 读高度。
|
||||
// 地板是 barcode 刻度。最好/最差两个点标名。
|
||||
(()=>{
|
||||
const P=[['Editor',72,86],['Boards',58,74],['Docs',44,79],['Chat',38,62],['Flows',66,58],
|
||||
['Vault',82,71],['Pages',28,55],['Sync',52,49],['Grid',88,44],['Views',20,68],
|
||||
['Hub',76,32],['Forms',34,38]];
|
||||
obsReveal('plumb',s=>{
|
||||
const X0=48,X1=368,base=258,mapX=p=>X0+p/100*(X1-X0),mapY=v=>base-(v-20)*2.6;
|
||||
// barcode floor
|
||||
for(let g=0;g<=20;g++){
|
||||
const x=X0+g/20*(X1-X0);
|
||||
el(s,'line',{x1:x,y1:base,x2:x,y2:base-(g%5===0?7:4),stroke:'#CFCEC7','stroke-width':.6,
|
||||
class:'fade',style:`animation-delay:${g*.01}s`});
|
||||
}
|
||||
el(s,'line',{x1:X0-6,y1:base,x2:X1+6,y2:base,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
txt(s,{x:X0,y:base+16,'font-size':7,'font-weight':600,fill:'#C6C5BF',class:'fade'},'CHEAP');
|
||||
txt(s,{x:X1,y:base+16,'font-size':7,'font-weight':600,fill:'#C6C5BF','text-anchor':'end',
|
||||
class:'fade'},'PREMIUM');
|
||||
const best=P.reduce((a,b)=>b[2]>a[2]?b:a),worst=P.reduce((a,b)=>b[2]<a[2]?b:a);
|
||||
P.forEach(([name,px,sat],i)=>{
|
||||
const x=mapX(px),y=mapY(sat),hero=name===best[0]||name===worst[0];
|
||||
el(s,'line',{x1:x,y1:base,x2:x,y2:y,stroke:'#B0AFA9','stroke-width':.55,
|
||||
opacity:.6,class:'fade',style:`animation-delay:${.2+i*.05}s`});
|
||||
const dot=el(s,'circle',{cx:x,cy:y,r:hero?4.6:2.6,fill:hero?INK:'#55554F',
|
||||
class:'pop',style:`animation-delay:${.25+i*.05}s`});
|
||||
tip(dot,`${name} — price ${px} · satisfaction ${sat}`);
|
||||
if(hero)txt(s,{x,y:y-10,'font-size':8.5,'font-weight':800,fill:INK,'text-anchor':'middle',
|
||||
style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:${.8}s`,
|
||||
class:'fade'},`${name} · ${sat}`);
|
||||
});
|
||||
txt(s,{x:26,y:mapY(85),'font-size':7,'font-weight':600,fill:'#C6C5BF',
|
||||
transform:`rotate(-90 26 ${mapY(85)})`,'text-anchor':'end','letter-spacing':'.08em',class:'fade'},'HAPPIER ↑');
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1s'},
|
||||
'EVERY DOT HANGS A PLUMB LINE · READ X AT THE FLOOR');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C5 · rung waterfall ════
|
||||
// 瀑布:从毛收入走到净利,每级一把小梯子。加项实档、减项空心档(stroke 描边格)。
|
||||
// 层与层之间虚线台阶承接。
|
||||
(()=>{
|
||||
const D=[['GROSS',42,null],['REFUNDS',-6,null],['COGS',-11,null],['OPS',-8,null],['NET',17,'total']];
|
||||
obsReveal('fall',s=>{
|
||||
const x0=i=>58+i*72,step=5.2,HW=11;
|
||||
let run=0;
|
||||
const levels=[];
|
||||
D.forEach(([name,v],i)=>{levels.push([name,v,run]);if(v!==17||name!=='NET')run+=(name==='GROSS'?v:(name==='NET'?0:v))});
|
||||
// recompute cleanly: running level after each step
|
||||
let lv=0;const rows=[];
|
||||
D.forEach(([name,v],i)=>{
|
||||
if(name==='GROSS'){rows.push([name,v,0,v]);lv=v}
|
||||
else if(name==='NET'){rows.push([name,lv,0,lv])}
|
||||
else{rows.push([name,v,lv+v,lv]);lv=lv+v}
|
||||
});
|
||||
const base=252,yOf=k=>base-k*step;
|
||||
rows.forEach(([name,v,lo,hi],i)=>{
|
||||
const x=x0(i),isTotal=name==='GROSS'||name==='NET',neg=v<0&&!isTotal;
|
||||
const from=isTotal?0:lo,to=isTotal?v:hi,n=Math.abs(to-from);
|
||||
for(let k=0;k<n;k++){
|
||||
const y=yOf(from+k),w=HW-1.2+rnd(k+1,i+2)*2.4;
|
||||
if(neg)el(s,'line',{x1:x-w,y1:y,x2:x+w,y2:y,stroke:'#8F8E88','stroke-width':1,
|
||||
'stroke-dasharray':'2.5 2.5',opacity:.7,class:'fade',
|
||||
style:`animation-delay:${i*.12+k*.014}s`});
|
||||
else el(s,'line',{x1:x-w,y1:y,x2:x+w,y2:y,stroke:INK,'stroke-width':1,
|
||||
opacity:.6+rnd(k+2,i+4)*.4,class:'fade',style:`animation-delay:${i*.12+k*.014}s`});
|
||||
}
|
||||
// step hand-off: dashed hairline to the next column's start level
|
||||
if(i<rows.length-1){
|
||||
const nx=x0(i+1),yl=yOf(isTotal?v:hi- (neg?0:0));
|
||||
const lvl=isTotal?v:(neg?lo:hi);
|
||||
el(s,'line',{x1:x+HW+2,y1:yOf(lvl),x2:nx-HW-2,y2:yOf(lvl),stroke:'#C6C5BF',
|
||||
'stroke-width':.7,'stroke-dasharray':'2 3',class:'fade',
|
||||
style:`animation-delay:${.3+i*.12}s`});
|
||||
}
|
||||
const topY=yOf(Math.max(from,to));
|
||||
const num=txt(s,{x,y:topY-8,'font-size':10,'font-weight':800,
|
||||
fill:neg?'#8F8E88':INK,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${.4+i*.12}s`},(neg?'−':'')+Math.abs(v));
|
||||
tip(num,`${name} — ${neg?'−':''}$${Math.abs(v)}k`);
|
||||
txt(s,{x,y:base+18,'font-size':7.5,'font-weight':700,fill:MUTED,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.12}s`},name);
|
||||
});
|
||||
el(s,'line',{x1:30,y1:base+4,x2:370,y2:base+4,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
txt(s,{x:200,y:302,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
|
||||
'SOLID RUNGS ADD · DASHED RUNGS TAKE AWAY · ONE RUNG = $1K');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C6 · dot heat ════
|
||||
// 热力图:punch card 的 Lupi 皮。7×12 网格,点面积 = 工单量,
|
||||
// 静默格留一粒极小点(沉默可见)。最热格标数。
|
||||
(()=>{
|
||||
const DAY=['MON','TUE','WED','THU','FRI','SAT','SUN'];
|
||||
obsReveal('dotheat',s=>{
|
||||
const x0=j=>64+j*27,y0=i=>58+i*30;
|
||||
let max=0,mi=0,mj=0;
|
||||
const v=(i,j)=>{
|
||||
const day=i<5?1:.32; // weekends quiet
|
||||
const hour=Math.exp(-((j-4.6)**2)/7)+.7*Math.exp(-((j-8.4)**2)/5); // two peaks
|
||||
const raw=day*hour*22*(0.6+rnd(i*12+j+1,j+3)*.8);
|
||||
return Math.round(raw);
|
||||
};
|
||||
for(let i=0;i<7;i++)for(let j=0;j<12;j++){const t=v(i,j);if(t>max){max=t;mi=i;mj=j}}
|
||||
for(let i=0;i<7;i++){
|
||||
txt(s,{x:50,y:y0(i)+3,'font-size':7.5,'font-weight':700,fill:'#6A6963','text-anchor':'end',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.05}s`},DAY[i]);
|
||||
for(let j=0;j<12;j++){
|
||||
const t=v(i,j),x=x0(j),y=y0(i);
|
||||
if(!t){el(s,'circle',{cx:x,cy:y,r:.8,fill:'#D8D6CE',
|
||||
class:'pop',style:`animation-delay:${i*.05+j*.015}s`});continue}
|
||||
const hero=i===mi&&j===mj;
|
||||
const dot=el(s,'circle',{cx:x,cy:y,r:1.2+Math.sqrt(t)*2.1,
|
||||
fill:t>max*.66?INK:t>max*.33?'#6A6963':'#B0AFA9',
|
||||
class:'pop',style:`animation-delay:${i*.05+j*.015}s`});
|
||||
tip(dot,`${DAY[i]} ${8+j}:00 — ${t} tickets`);
|
||||
if(hero)el(s,'circle',{cx:x,cy:y,r:1.2+Math.sqrt(t)*2.1+3.4,fill:'none',
|
||||
stroke:INK,'stroke-width':1,'stroke-dasharray':'2 3',
|
||||
class:'fade',style:'animation-delay:1s'});
|
||||
}
|
||||
}
|
||||
for(let j=0;j<12;j+=2)
|
||||
txt(s,{x:x0(j),y:y0(6)+26,'font-size':7,'font-weight':600,fill:'#C6C5BF','text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${j*.02}s`},(8+j)+':00');
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
|
||||
`DOT AREA = TICKETS · DASHED RING = THE PEAK, ${max} · TINY DOT = A QUIET HOUR`);
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C7 · tick gauge ════
|
||||
// 进度:ballot tick 弯成 210° 表盘,1 tick = 1% 目标。走到 73 上墨收笔。
|
||||
// 里程碑 25/50/75/100 点标 + 小字。中心大数 + 剩余量小字。
|
||||
(()=>{
|
||||
const GOAL=73;
|
||||
obsReveal('gauge',s=>{
|
||||
const cx=200,cy=190,R0=104,A0=-195,SW=210; // -195° → +15°
|
||||
for(let k=0;k<100;k++){
|
||||
const a=A0+k/100*SW,inked=k<GOAL;
|
||||
const len=inked?13+rnd(k+1,3)*6:5+rnd(k+1,7)*2.5;
|
||||
const [x1,y1]=pol(cx,cy,R0,a),[x2,y2]=pol(cx,cy,R0+len,a);
|
||||
el(s,'line',{x1,y1,x2,y2,stroke:inked?INK:'#CFCEC7','stroke-width':inked?1:.6,
|
||||
class:'fade',style:`animation-delay:${k*.012}s`});
|
||||
}
|
||||
[25,50,75,100].forEach(m=>{
|
||||
const a=A0+m/100*SW,[dx,dy]=pol(cx,cy,R0-7,a),[tx2,ty2]=pol(cx,cy,R0-19,a);
|
||||
el(s,'circle',{cx:dx,cy:dy,r:1,fill:'#B0AFA9',class:'fade',style:'animation-delay:.8s'});
|
||||
txt(s,{x:tx2,y:ty2+3,'font-size':7,'font-weight':600,fill:'#C6C5BF','text-anchor':'middle',
|
||||
class:'fade',style:'animation-delay:.85s'},m);
|
||||
});
|
||||
// inked tip bead
|
||||
const aT=A0+GOAL/100*SW,[ex,ey]=pol(cx,cy,R0+20,aT);
|
||||
el(s,'circle',{cx:ex,cy:ey,r:2.4,fill:INK,class:'pop',style:'animation-delay:1.1s'});
|
||||
const num=txt(s,{x:cx,y:cy-4,'font-size':34,'font-weight':800,fill:INK,'text-anchor':'middle',
|
||||
class:'fade',style:'animation-delay:1s'},GOAL+'%');
|
||||
tip(num,`$730k of the $1M quarter target`);
|
||||
txt(s,{x:cx,y:cy+16,'font-size':8,'font-weight':600,fill:MUTED,'text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade',style:'animation-delay:1.05s'},'27 TICKS TO GO');
|
||||
txt(s,{x:200,y:300,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
|
||||
'ONE TICK = 1% OF TARGET · INKED = EARNED');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C8 · dumbbell queue ════
|
||||
// 哑铃对比:空心点=改版前,实心点=改版后,两点之间的轨道上串珠,
|
||||
// 一珠 = 省下的一分钟,可数。全部向左走(变快),没有交叉没有 X。
|
||||
(()=>{
|
||||
const D=[['INVITE FLOW',14,6],['FIRST BOARD',19,9],['IMPORT DATA',26,13],['TEAM SETUP',31,21],['GO LIVE',38,30]];
|
||||
obsReveal('dumbbell',s=>{
|
||||
const y0=i=>56+i*46,X0=118,X1=364,mapX=v=>X0+(v-4)/36*(X1-X0);
|
||||
D.forEach(([name,was,now],i)=>{
|
||||
const y=y0(i),xa=mapX(was),xb=mapX(now);
|
||||
txt(s,{x:108,y:y+3,'font-size':7.5,'font-weight':700,fill:'#6A6963','text-anchor':'end',
|
||||
'letter-spacing':'.06em',class:'fade',style:`animation-delay:${i*.08}s`},name);
|
||||
el(s,'line',{x1:X0-6,y1:y,x2:X1+6,y2:y,stroke:'#E3E2DB','stroke-width':.7,
|
||||
class:'fade',style:`animation-delay:${i*.08}s`});
|
||||
// beads: one per minute saved, strung between the two dots
|
||||
const n=was-now;
|
||||
for(let k=0;k<n;k++){
|
||||
const t=(k+.5)/n,x=xb+t*(xa-xb),yy=y+(rnd(k+1,i+3)-.5)*2.6;
|
||||
el(s,'circle',{cx:x,cy:yy,r:1.5+rnd(k+2,i+4)*.9,fill:'#8F8E88',opacity:.85,
|
||||
class:'pop',style:`animation-delay:${.3+i*.08+k*.03}s`});
|
||||
}
|
||||
el(s,'circle',{cx:xa,cy:y,r:4.2,fill:PAPER,stroke:INK,'stroke-width':1.3,
|
||||
class:'pop',style:`animation-delay:${.2+i*.08}s`});
|
||||
const after=el(s,'circle',{cx:xb,cy:y,r:4.6,fill:INK,
|
||||
class:'pop',style:`animation-delay:${.6+i*.08}s`});
|
||||
tip(after,`${name} — ${was} min → ${now} min`);
|
||||
txt(s,{x:xa+10,y:y-8,'font-size':8.5,'font-weight':700,fill:'#B0AFA9',
|
||||
class:'fade',style:`animation-delay:${.3+i*.08}s`},was);
|
||||
txt(s,{x:xb-10,y:y-8,'font-size':10,'font-weight':800,fill:INK,'text-anchor':'end',
|
||||
class:'fade',style:`animation-delay:${.7+i*.08}s`},now);
|
||||
});
|
||||
txt(s,{x:X0,y:290,'font-size':7,'font-weight':600,fill:'#C6C5BF',class:'fade'},'FASTER ←');
|
||||
txt(s,{x:X1,y:290,'font-size':7,'font-weight':600,fill:'#C6C5BF','text-anchor':'end',
|
||||
class:'fade'},'MINUTES');
|
||||
txt(s,{x:200,y:308,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1s'},
|
||||
'ONE BEAD = ONE MINUTE SAVED · HOLLOW = BEFORE · INK = AFTER');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C9 · nested treemap ════
|
||||
// 面积只编码工时;Mono 不用随机灰阶区分类目,父级靠标题带和组间留白分开。
|
||||
(()=>{
|
||||
const TOTAL=1280,FILL='#55554F';
|
||||
const leaf=(name,value)=>({name,value,itemStyle:{color:FILL},label:{color:PAPER}});
|
||||
const DATA=[
|
||||
{name:'PRODUCT · 50%',children:[leaf('Core app',260),leaf('Collaboration',190),leaf('Search',110),leaf('Mobile',80)]},
|
||||
{name:'PLATFORM · 31%',children:[leaf('AI systems',170),leaf('Data infra',120),leaf('APIs',105)]},
|
||||
{name:'GROWTH · 19%',children:[leaf('Acquisition',90),leaf('Retention',80),leaf('Onboarding',75)]},
|
||||
];
|
||||
const host=document.getElementById('treemap');
|
||||
let chart;
|
||||
const draw=()=>{
|
||||
chart=chart||echarts.init(host,null,{renderer:'svg'});
|
||||
chart.clear();
|
||||
chart.setOption({animationDuration:900,animationEasing:'quarticOut',aria:{enabled:true},
|
||||
tooltip:{backgroundColor:PAPER,borderColor:INK,borderWidth:1,textStyle:{color:INK,fontSize:12},
|
||||
formatter:i=>`${i.treePathInfo.slice(1).map(d=>d.name.replace(/ · .*$/,'')).join(' / ')}<br><b>${i.value} hours</b> · ${(i.value/TOTAL*100).toFixed(1)}%`},
|
||||
series:[{type:'treemap',data:DATA,top:0,right:0,bottom:0,left:0,roam:false,nodeClick:false,
|
||||
breadcrumb:{show:false},leafDepth:2,squareRatio:1.2,
|
||||
label:{show:true,position:'insideTopLeft',padding:[10,8],color:INK,fontSize:12,fontWeight:700,lineHeight:18,
|
||||
formatter:p=>p.treePathInfo.length<=1?'':p.treePathInfo.length===2?p.name:`${p.name}\n${p.value} h`},
|
||||
upperLabel:{show:true,height:32,padding:[0,10],color:INK,fontSize:10,fontWeight:700,backgroundColor:PAPER},
|
||||
itemStyle:{borderColor:PAPER,borderWidth:2,gapWidth:2},
|
||||
emphasis:{focus:'ancestor',itemStyle:{borderColor:INK,borderWidth:1.5}},
|
||||
levels:[{itemStyle:{borderWidth:0,gapWidth:5}},
|
||||
{upperLabel:{show:true,height:32},itemStyle:{borderColor:PAPER,borderWidth:3,gapWidth:3}},
|
||||
{upperLabel:{show:false},itemStyle:{borderColor:PAPER,borderWidth:2,gapWidth:2}}]}]});
|
||||
};
|
||||
const io=new IntersectionObserver(es=>{if(es[0].isIntersecting){draw();io.disconnect()}},{threshold:.25});
|
||||
io.observe(host);
|
||||
host.style.cursor='pointer';
|
||||
host.addEventListener('click',draw);
|
||||
addEventListener('resize',()=>chart?.resize());
|
||||
})();
|
||||
|
||||
// ── New chart-family helpers (scoped to the added templates) ──
|
||||
(()=>{
|
||||
const INK='#1C1C1A',PAPER='#F0EFEB',MUTED='#8F8E88',GRID='#DEDDD6';
|
||||
const NS='http://www.w3.org/2000/svg';
|
||||
const el=(p,t,a)=>{const n=document.createElementNS(NS,t);for(const k in a)n.setAttribute(k,a[k]);p.appendChild(n);return n};
|
||||
const txt=(p,a,s)=>{const n=el(p,'text',a);n.textContent=s;return n};
|
||||
const tip=(n,s)=>{const t=document.createElementNS(NS,'title');t.textContent=s;n.appendChild(t)};
|
||||
const rnd=(i,k)=>Math.abs(((i*73856093)^(k*19349663))%1000)/1000;
|
||||
|
||||
// ════ F14 · rung histogram ════
|
||||
// 直方图的 Lupi Basics 皮:连续数值分箱,一箱一把梯子,1 档 = 1 张工单
|
||||
// (百分之一)。箱界写在梯脚之间——直方图的箱是区间不是类目,
|
||||
// 这是它和 F1 Rung Bars 的本质区别。峰值箱标数,中位数落点小旗标注。
|
||||
(()=>{
|
||||
const BIN=[6,14,22,19,13,9,6,4,3,2,1,1]; // 2h bins, sums to 100
|
||||
obsReveal('histo',s=>{
|
||||
const X0=44,PW=27.5,base=252,step=5.4,HW=10.5;
|
||||
BIN.forEach((v,i)=>{
|
||||
const x=X0+i*PW+PW/2;
|
||||
for(let k=0;k<v;k++){
|
||||
const y=base-k*step,w=HW-1.4+rnd(k+1,i+2)*2.8;
|
||||
el(s,'line',{x1:x-w,y1:y,x2:x+w,y2:y,stroke:INK,'stroke-width':1,
|
||||
opacity:.55+rnd(k+2,i+4)*.45,class:'fade',style:`animation-delay:${i*.06+k*.012}s`});
|
||||
if(k%5===4)el(s,'circle',{cx:x+HW+3.5,cy:y,r:.7,fill:'#C6C5BF',
|
||||
class:'fade',style:`animation-delay:${i*.06+k*.012}s`});
|
||||
}
|
||||
});
|
||||
// peak label
|
||||
const pk=BIN.indexOf(Math.max(...BIN));
|
||||
const px=X0+pk*PW+PW/2;
|
||||
const num=txt(s,{x:px,y:base-(BIN[pk]-1)*step-10,'font-size':11,'font-weight':800,fill:INK,
|
||||
'text-anchor':'middle',class:'fade',style:'animation-delay:.8s'},BIN[pk]);
|
||||
tip(num,`${BIN[pk]} of 100 tickets took ${pk*2}–${pk*2+2}h`);
|
||||
// bin edges: the continuous axis is the histogram's identity
|
||||
el(s,'line',{x1:X0-4,y1:base+4,x2:X0+12*PW+4,y2:base+4,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
for(let e=0;e<=12;e++){
|
||||
const x=X0+e*PW;
|
||||
el(s,'line',{x1:x,y1:base+4,x2:x,y2:base+(e%2===0?11:8),stroke:'#CFCEC7','stroke-width':.6,
|
||||
class:'fade',style:`animation-delay:${e*.02}s`});
|
||||
if(e%2===0)txt(s,{x,y:base+21,'font-size':7,'font-weight':600,fill:MUTED,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${e*.02}s`},e*2+'h');
|
||||
}
|
||||
// median flag: cumulative crosses 50 inside bin 2
|
||||
let acc=0,mb=0;for(let i=0;i<12;i++){acc+=BIN[i];if(acc>=50){mb=i;break}}
|
||||
const mx=X0+mb*PW+PW*.7;
|
||||
el(s,'line',{x1:mx,y1:base+4,x2:mx,y2:base-BIN[mb]*step-16,stroke:'#8F8E88','stroke-width':.9,
|
||||
'stroke-dasharray':'2 4',class:'fade',style:'animation-delay:1s'});
|
||||
txt(s,{x:mx+4,y:base-BIN[mb]*step-20,'font-size':7.5,'font-weight':800,fill:'#6A6963',
|
||||
class:'fade',style:'animation-delay:1.1s'},'HALF RESOLVED BY HERE');
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
|
||||
'ONE RUNG = ONE TICKET IN A HUNDRED · BINS OF TWO HOURS · DOT MARKS EVERY FIFTH');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ F15 · tick box ════
|
||||
// 箱线图的 Basics 皮:发丝须线 + 胶囊箱体(IQR),纸色横档 = 中位数,
|
||||
// 空心点 = 离群值。箱体明度按中位数快慢排:越快越黑。
|
||||
(()=>{
|
||||
const G=[['ENT',[.4,.9,1.5,2.6,4.4],[6.2]],
|
||||
['PRO',[.8,2.1,3.3,5,7.8],[10.5]],
|
||||
['STARTER',[1.5,3.8,6.1,8.9,13.2],[16.8]],
|
||||
['FREE',[2.2,6,9.4,13.8,19.6],[22.1,23.5]]];
|
||||
const SHADE=[INK,'#4A4944','#8F8E88','#B0AFA9'];
|
||||
obsReveal('boxplot',s=>{
|
||||
const TOP=42,BASE=260,MAXH=24,mapY=v=>BASE-v/MAXH*(BASE-TOP);
|
||||
const colX=g=>78+g*84,BW=24;
|
||||
[0,6,12,18,24].forEach(h=>{
|
||||
el(s,'line',{x1:36,y1:mapY(h),x2:378,y2:mapY(h),stroke:'#E3E2DB','stroke-width':.8,
|
||||
class:'fade',style:`animation-delay:${h*.008}s`});
|
||||
txt(s,{x:30,y:mapY(h)+3,'font-size':7.5,'font-weight':600,fill:MUTED,'text-anchor':'end',
|
||||
class:'fade',style:`animation-delay:${h*.008}s`},h+'h');
|
||||
});
|
||||
G.forEach(([name,[mn,q1,md,q3,mx],outs],g)=>{
|
||||
const x=colX(g);
|
||||
el(s,'line',{x1:x,y1:mapY(mn),x2:x,y2:mapY(mx),stroke:'#8F8E88','stroke-width':.8,
|
||||
pathLength:1,class:'draw',style:`animation-delay:${g*.12}s;animation-duration:.6s`});
|
||||
[mn,mx].forEach(vv=>el(s,'line',{x1:x-7,y1:mapY(vv),x2:x+7,y2:mapY(vv),
|
||||
stroke:'#8F8E88','stroke-width':1,class:'fade',style:`animation-delay:${.2+g*.12}s`}));
|
||||
const box=el(s,'rect',{x:x-BW/2,y:mapY(q3),width:BW,height:mapY(q1)-mapY(q3),rx:9,
|
||||
fill:SHADE[g],class:'pop',style:`animation-delay:${.15+g*.12}s`});
|
||||
tip(box,`${name} — half of tickets answered in ${q1}–${q3}h`);
|
||||
el(s,'line',{x1:x-BW/2+3,y1:mapY(md),x2:x+BW/2-3,y2:mapY(md),stroke:PAPER,'stroke-width':2.2,
|
||||
class:'fade',style:`animation-delay:${.5+g*.12}s`});
|
||||
txt(s,{x:x+BW/2+6,y:mapY(md)+3,'font-size':9.5,'font-weight':800,fill:INK,
|
||||
class:'fade',style:`animation-delay:${.6+g*.12}s`},md.toFixed(1)+'h');
|
||||
outs.forEach((o,k)=>{
|
||||
const d=el(s,'circle',{cx:x+(rnd(k+1,g+3)-.5)*8,cy:mapY(o),r:2.6,fill:PAPER,
|
||||
stroke:'#6A6963','stroke-width':1.1,class:'pop',style:`animation-delay:${.7+g*.12+k*.06}s`});
|
||||
tip(d,`${name} outlier — ${o}h`);
|
||||
});
|
||||
txt(s,{x,y:BASE+18,'font-size':7.5,'font-weight':700,fill:MUTED,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${g*.12}s`},name);
|
||||
});
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
|
||||
'BOX = MIDDLE HALF · PAPER TICK = MEDIAN · HOLLOW = OUTLIER · DARKEST = FASTEST');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ F16 · stream ribbon ════
|
||||
// Streamgraph / ThemeRiver:多系列构成随时间此消彼长。轮廓对称于中轴
|
||||
// (silhouette 基线),带宽 = 该系列当周量,纸色缝分带(treemap 同法)。
|
||||
// 三带明度按当前份额排:现任霸主最黑。带名标在各自最宽处。
|
||||
(()=>{
|
||||
const N=48;
|
||||
const mk=(base,trend,w1,w2,seed)=>Array.from({length:N},(_,t)=>
|
||||
Math.max(2,base+trend*t+10*Math.sin(t/w1+seed)+5*Math.sin(t/w2+seed*2)+rnd(t+1,seed)*6));
|
||||
const SERIES=[
|
||||
['LEGACY EDITOR',mk(46,-.62,9,3.7,2),'#C6C5BF'],
|
||||
['BOARDS',mk(26,.10,11,4.2,5),'#8F8E88'],
|
||||
['FLOWS',mk(12,.78,10,3.1,8),INK],
|
||||
];
|
||||
obsReveal('stream',s=>{
|
||||
const X0=36,X1=744,CY=138,SC=1.15;
|
||||
const x=t=>X0+t/(N-1)*(X1-X0);
|
||||
const tot=Array.from({length:N},(_,t)=>SERIES.reduce((a,S)=>a+S[1][t],0));
|
||||
// stacked offsets, silhouette baseline: top of stack starts at -total/2
|
||||
const y0=Array.from({length:N},(_,t)=>CY-tot[t]*SC/2);
|
||||
const smooth=pts=>{
|
||||
let d=`M${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;
|
||||
for(let k=1;k<pts.length-1;k++){
|
||||
const p=pts[k],q=pts[k+1];
|
||||
d+=` Q${p[0].toFixed(1)} ${p[1].toFixed(1)} ${((p[0]+q[0])/2).toFixed(1)} ${((p[1]+q[1])/2).toFixed(1)}`;
|
||||
}
|
||||
return d+` L${pts[pts.length-1][0].toFixed(1)} ${pts[pts.length-1][1].toFixed(1)}`;
|
||||
};
|
||||
let run=y0.slice();
|
||||
SERIES.forEach(([name,vals,shade],si)=>{
|
||||
const top=run.map((v,t)=>[x(t),v]);
|
||||
const bot=run.map((v,t)=>[x(t),v+vals[t]*SC]);
|
||||
run=bot.map(p=>p[1]);
|
||||
const d=smooth(top)+' L '+[...bot].reverse().map(p=>`${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' L ')+' Z';
|
||||
const band=el(s,'path',{d,fill:shade,stroke:PAPER,'stroke-width':2,
|
||||
class:'fade',style:`animation-delay:${si*.16}s`});
|
||||
tip(band,`${name} — weekly active accounts, 48 weeks`);
|
||||
// label at the band's widest week — clamped off both ends so the
|
||||
// paper-colored text never spills past the band onto paper (invisible)
|
||||
let wk=4;vals.forEach((v,t)=>{if(t>=4&&t<=N-6&&v>vals[wk])wk=t});
|
||||
const midY=(top[wk][1]+bot[wk][1])/2;
|
||||
const dark=shade===INK||shade==='#4A4944';
|
||||
txt(s,{x:x(wk),y:midY+3,'font-size':9,'font-weight':800,'text-anchor':'middle',
|
||||
fill:dark?PAPER:'#4A4944','letter-spacing':'.06em',
|
||||
class:'fade',style:`animation-delay:${.5+si*.14}s`},name);
|
||||
});
|
||||
// month floor: barcode ticks + labels every 8 weeks
|
||||
const base=252;
|
||||
el(s,'line',{x1:X0-6,y1:base,x2:X1+6,y2:base,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
for(let t=0;t<N;t++)
|
||||
el(s,'line',{x1:x(t),y1:base,x2:x(t),y2:base-(t%8===0?7:4),stroke:'#CFCEC7','stroke-width':.6,
|
||||
class:'fade',style:`animation-delay:${t*.006}s`});
|
||||
['JAN','MAR','MAY','JUL','SEP','NOV'].forEach((m,k)=>
|
||||
txt(s,{x:x(k*8),y:base+15,'font-size':7.5,'font-weight':600,fill:MUTED,'text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade'},m));
|
||||
txt(s,{x:390,y:288,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
|
||||
'BAND WIDTH = WEEKLY ACTIVES · DARKEST = TODAY’S LEADER · THE RIVER IS THE TOTAL');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ F17 · candlestick ════
|
||||
// K 线的 Mono 化:不用红绿——实心墨 = 收跌,空心(纸底墨框)= 收涨,
|
||||
// 和哑铃图 hollow/ink 的语义一脉。影线是发丝,柱体胶囊圆角。
|
||||
// OHLC 四值都是真实记录,最高/最低两日标数。30 个交易日。
|
||||
(()=>{
|
||||
const N=30;
|
||||
// deterministic random walk with a dip-and-recover arc
|
||||
const DAYS=[];
|
||||
let px=52;
|
||||
for(let d=0;d<N;d++){
|
||||
const drift=d<10?.4:d<18?-1.5:1.3;
|
||||
const open=px;
|
||||
const close=Math.max(30,open+drift+(rnd(d+1,3)-.5)*4.6);
|
||||
const hi=Math.max(open,close)+rnd(d+2,7)*2.6;
|
||||
const lo=Math.min(open,close)-rnd(d+3,11)*2.6;
|
||||
DAYS.push([open,close,hi,lo]);
|
||||
px=close;
|
||||
}
|
||||
obsReveal('candle',s=>{
|
||||
const X0=40,PW=11.2,BASE=258,TOP=44;
|
||||
const vmin=Math.min(...DAYS.map(d=>d[3])),vmax=Math.max(...DAYS.map(d=>d[2]));
|
||||
const mapY=v=>BASE-(v-vmin)/(vmax-vmin)*(BASE-TOP);
|
||||
const x=d=>X0+d*PW+PW/2;
|
||||
// price grid
|
||||
for(let g=0;g<=4;g++){
|
||||
const v=vmin+g/4*(vmax-vmin);
|
||||
el(s,'line',{x1:X0-4,y1:mapY(v),x2:X0+N*PW+4,y2:mapY(v),stroke:'#E3E2DB','stroke-width':.8,
|
||||
class:'fade',style:`animation-delay:${g*.03}s`});
|
||||
txt(s,{x:X0-8,y:mapY(v)+3,'font-size':7.5,'font-weight':600,fill:MUTED,'text-anchor':'end',
|
||||
class:'fade',style:`animation-delay:${g*.03}s`},'$'+Math.round(v));
|
||||
}
|
||||
let hiD=0,loD=0;
|
||||
DAYS.forEach(([o,c,h,l],d)=>{
|
||||
if(h>DAYS[hiD][2])hiD=d;
|
||||
if(l<DAYS[loD][3])loD=d;
|
||||
});
|
||||
DAYS.forEach(([o,c,h,l],d)=>{
|
||||
const cx=x(d),up=c>=o;
|
||||
const yT=mapY(Math.max(o,c)),yB=mapY(Math.min(o,c));
|
||||
// wick: one hairline, full reach
|
||||
el(s,'line',{x1:cx,y1:mapY(h),x2:cx,y2:mapY(l),stroke:'#6A6963','stroke-width':.7,
|
||||
class:'fade',style:`animation-delay:${d*.03}s`});
|
||||
// body: capsule; hollow = up, ink = down
|
||||
const body=el(s,'rect',{x:cx-3.4,y:yT,width:6.8,height:Math.max(2.5,yB-yT),rx:3,
|
||||
...(up?{fill:PAPER,stroke:INK,'stroke-width':1.1}:{fill:INK}),
|
||||
class:'fade',style:`animation-delay:${.05+d*.03}s`});
|
||||
tip(body,`Day ${d+1} — open $${o.toFixed(1)} · close $${c.toFixed(1)} · high $${h.toFixed(1)} · low $${l.toFixed(1)}`);
|
||||
// extremes, labeled with hairline flags
|
||||
if(d===hiD)txt(s,{x:cx,y:mapY(h)-7,'font-size':8,'font-weight':800,fill:INK,'text-anchor':'middle',
|
||||
style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:1s`,
|
||||
class:'fade'},'$'+Math.round(h));
|
||||
if(d===loD)txt(s,{x:cx,y:mapY(l)+14,'font-size':8,'font-weight':800,fill:'#6A6963','text-anchor':'middle',
|
||||
style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:1s`,
|
||||
class:'fade'},'$'+Math.round(l));
|
||||
});
|
||||
// week ticks on the floor
|
||||
el(s,'line',{x1:X0-4,y1:BASE+8,x2:X0+N*PW+4,y2:BASE+8,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
for(let d=0;d<N;d++)
|
||||
el(s,'line',{x1:x(d),y1:BASE+8,x2:x(d),y2:BASE+8-(d%5===0?6:3),stroke:'#CFCEC7','stroke-width':.6,
|
||||
class:'fade',style:`animation-delay:${d*.008}s`});
|
||||
['W1','W2','W3','W4','W5','W6'].forEach((w,k)=>
|
||||
txt(s,{x:x(k*5),y:BASE+21,'font-size':7,'font-weight':600,fill:MUTED,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade'},w));
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#B0AFA9','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
|
||||
'INK BODY = CLOSED DOWN · HOLLOW = CLOSED UP · WICK = THE DAY’S FULL REACH');
|
||||
});
|
||||
})();
|
||||
})();
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,112 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Mono — Circular Graph, Dense</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@6/dist/echarts.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--bg:#F0EFEB;--dark:#1C1C1A}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);font-family:'Inter',sans-serif;padding:40px;-webkit-font-smoothing:antialiased;
|
||||
display:flex;justify-content:center}
|
||||
.card{background:var(--dark);color:#F0EFEB;border-radius:24px;padding:34px 34px 24px;
|
||||
width:min(1100px,100%)}
|
||||
h2{font-weight:700;font-size:19px;letter-spacing:-.02em;margin-bottom:4px}
|
||||
.sub{font-size:12px;color:#8F8E88;margin-bottom:10px}
|
||||
.src{font-size:9.5px;color:#55554F;margin-top:12px;letter-spacing:.08em;font-weight:500}
|
||||
#ch{height:78vh;min-height:620px;cursor:pointer}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>Six hundred collaborations, one ring</h2>
|
||||
<div class="sub">60 repos · line = shared contributors · hover a repo to light up its world · click to replay</div>
|
||||
<div id="ch"></div>
|
||||
<div class="src">CIRCULAR GRAPH · DENSE · SOURCE · GIT LOG</div>
|
||||
</div>
|
||||
<script>
|
||||
const INK='#1C1C1A',PAPER='#F0EFEB';
|
||||
const rnd=(i,k)=>((i*73856093)^(k*19349663))%1000/1000; // deterministic
|
||||
|
||||
// ── 60 repos in 5 org clusters; cluster = shade family ──
|
||||
const ORGS=[
|
||||
['platform',16,'#F0EFEB'], // paper — the core
|
||||
['product',14,'#C9C7BD'],
|
||||
['data',12,'#A5A39A'],
|
||||
['infra',10,'#8F8E88'],
|
||||
['labs',8,'#6A6963'],
|
||||
];
|
||||
const nodes=[];
|
||||
ORGS.forEach(([org,count],oi)=>{
|
||||
for(let i=0;i<count;i++){
|
||||
// activity: a few giants per cluster, long tail of small repos
|
||||
const heavy=i<2?30+rnd(oi+1,i+2)*45:4+rnd(oi+3,i+7)*16;
|
||||
nodes.push({
|
||||
name:org+'/'+org.slice(0,2)+'-'+String(i+1).padStart(2,'0'),
|
||||
org:oi,value:Math.round(heavy),
|
||||
});
|
||||
}
|
||||
});
|
||||
// links: dense inside a cluster, sparser across; weight = shared contributors
|
||||
const links=[];
|
||||
const idx={};nodes.forEach((n,i)=>idx[n.name]=i);
|
||||
nodes.forEach((a,i)=>{
|
||||
nodes.forEach((b,j)=>{
|
||||
if(j<=i)return;
|
||||
const same=a.org===b.org;
|
||||
const p=same?.16:.045;
|
||||
if(rnd(i+1,j+2)<p){
|
||||
links.push({source:i,target:j,
|
||||
w:1+Math.round(rnd(i+2,j+3)*6),same});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const g=echarts.init(document.getElementById('ch'));
|
||||
const opt={
|
||||
animationDuration:1800,animationEasing:'quarticOut',
|
||||
tooltip:{backgroundColor:PAPER,borderWidth:0,
|
||||
textStyle:{color:INK,fontFamily:'Inter',fontSize:12},padding:[10,14],
|
||||
formatter:p=>p.dataType==='node'
|
||||
?p.name+' — '+p.value+' contributors'
|
||||
:p.data.rawS+' ↔ '+p.data.rawT+' — '+p.data.w+' shared'},
|
||||
series:[{
|
||||
type:'graph',layout:'circular',
|
||||
circular:{rotateLabel:true},
|
||||
left:24,right:24,top:24,bottom:24,
|
||||
data:nodes.map((n,i)=>({
|
||||
name:n.name,value:n.value,
|
||||
symbolSize:3.5+Math.sqrt(n.value)*1.7,
|
||||
itemStyle:{color:ORGS[n.org][2],borderWidth:0},
|
||||
// only the giants get a label — 60 names would be soup
|
||||
label:{show:n.value>=26,position:'right',distance:4,
|
||||
color:'#B3B0A4',fontFamily:'Inter',fontSize:8.5,fontWeight:600},
|
||||
})),
|
||||
links:links.map(l=>({
|
||||
source:l.source,target:l.target,
|
||||
rawS:nodes[l.source].name,rawT:nodes[l.target].name,w:l.w,
|
||||
lineStyle:{
|
||||
width:.4+l.w*.28,
|
||||
color:l.same?'#55534B':'#3A3934',
|
||||
opacity:l.same?.5:.32,
|
||||
curveness:.3,
|
||||
},
|
||||
})),
|
||||
emphasis:{
|
||||
focus:'adjacency',
|
||||
lineStyle:{color:PAPER,opacity:.9,width:1.6},
|
||||
label:{show:true,color:PAPER},
|
||||
},
|
||||
blur:{itemStyle:{opacity:.12},lineStyle:{opacity:.03}},
|
||||
}],
|
||||
};
|
||||
const play=()=>{g.clear();g.setOption(opt)};
|
||||
play();
|
||||
document.getElementById('ch').addEventListener('click',play);
|
||||
addEventListener('resize',()=>g.resize());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,111 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Mono — Force Graph, Dense</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@6/dist/echarts.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--bg:#F0EFEB;--dark:#1C1C1A}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);font-family:'Inter',sans-serif;padding:40px;-webkit-font-smoothing:antialiased;
|
||||
display:flex;justify-content:center}
|
||||
.card{background:var(--dark);color:#F0EFEB;border-radius:24px;padding:34px 34px 24px;
|
||||
width:min(1100px,100%)}
|
||||
h2{font-weight:700;font-size:19px;letter-spacing:-.02em;margin-bottom:4px}
|
||||
.sub{font-size:12px;color:#8F8E88;margin-bottom:10px}
|
||||
.src{font-size:9.5px;color:#55554F;margin-top:12px;letter-spacing:.08em;font-weight:500}
|
||||
#ch{height:78vh;min-height:620px;cursor:grab}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>A galaxy of one hundred eighty services</h2>
|
||||
<div class="sub">8 domains self-organize · node = call volume · drag anything, the galaxy fights back · click empty space to replay</div>
|
||||
<div id="ch"></div>
|
||||
<div class="src">FORCE GRAPH · DENSE · SOURCE · SERVICE MESH</div>
|
||||
</div>
|
||||
<script>
|
||||
const INK='#1C1C1A',PAPER='#F0EFEB';
|
||||
const rnd=(i,k)=>((i*73856093)^(k*19349663))%1000/1000; // deterministic
|
||||
|
||||
// ── 8 domain hubs, each with a swarm of services; hubs interconnect ──
|
||||
const DOMAINS=[
|
||||
['auth',26,'#F0EFEB'],['billing',24,'#DCDAD2'],['content',28,'#C9C7BD'],
|
||||
['search',20,'#B3B0A4'],['notify',22,'#9C9A91'],['media',20,'#8F8E88'],
|
||||
['analytics',18,'#7C7A72'],['edge',14,'#6A6963'],
|
||||
];
|
||||
const nodes=[],links=[];
|
||||
DOMAINS.forEach(([dom,count,shade],di)=>{
|
||||
// the hub
|
||||
nodes.push({name:dom,value:60+Math.round(rnd(di+1,3)*50),hub:true,shade,dom:di});
|
||||
// satellites: long-tail sizes, a couple of mid-weights per domain
|
||||
for(let i=0;i<count;i++){
|
||||
const v=i<3?14+rnd(di+2,i+4)*18:2+rnd(di+5,i+9)*9;
|
||||
const name=dom+'-'+String(i+1).padStart(2,'0');
|
||||
nodes.push({name,value:Math.round(v),hub:false,shade,dom:di});
|
||||
// satellite -> its hub
|
||||
links.push({source:name,target:dom,
|
||||
lineStyle:{width:.5+v*.05,color:'#3F3E38',opacity:.5}});
|
||||
// occasional satellite-to-satellite shortcut inside the domain
|
||||
if(i>1&&rnd(di+3,i+11)<.22){
|
||||
links.push({source:name,target:dom+'-'+String(1+Math.floor(rnd(di+4,i+13)*i)).padStart(2,'0'),
|
||||
lineStyle:{width:.5,color:'#33322D',opacity:.4}});
|
||||
}
|
||||
}
|
||||
});
|
||||
// hub-to-hub backbone: every domain talks to 2-3 others
|
||||
const H=DOMAINS.length;
|
||||
for(let a=0;a<H;a++)for(let b=a+1;b<H;b++){
|
||||
if(rnd(a+7,b+9)<.42){
|
||||
links.push({source:DOMAINS[a][0],target:DOMAINS[b][0],
|
||||
lineStyle:{width:1.6+rnd(a+2,b+3)*2.2,color:'#55534B',opacity:.65,curveness:.08}});
|
||||
}
|
||||
}
|
||||
// a few cross-domain satellite shortcuts — the messy real-world wires
|
||||
for(let k=0;k<26;k++){
|
||||
const a=nodes[1+Math.floor(rnd(k+1,17)*(nodes.length-1))];
|
||||
const b=nodes[1+Math.floor(rnd(k+3,23)*(nodes.length-1))];
|
||||
if(a.name!==b.name&&a.dom!==b.dom&&!a.hub&&!b.hub){
|
||||
links.push({source:a.name,target:b.name,
|
||||
lineStyle:{width:.5,color:'#2E2D29',opacity:.38,curveness:.15}});
|
||||
}
|
||||
}
|
||||
|
||||
const g=echarts.init(document.getElementById('ch'));
|
||||
const opt={
|
||||
animationDuration:300,
|
||||
tooltip:{backgroundColor:PAPER,borderWidth:0,
|
||||
textStyle:{color:INK,fontFamily:'Inter',fontSize:12},padding:[10,14],
|
||||
formatter:p=>p.dataType==='node'?p.name+' — '+p.value+'k calls/day':''},
|
||||
series:[{
|
||||
type:'graph',layout:'force',
|
||||
force:{repulsion:46,edgeLength:[10,42],gravity:.16,friction:.22,layoutAnimation:true},
|
||||
roam:true,draggable:true,
|
||||
left:6,right:6,top:6,bottom:6,
|
||||
data:nodes.map(n=>({
|
||||
name:n.name,value:n.value,
|
||||
symbolSize:n.hub?16+Math.sqrt(n.value)*1.6:2.5+Math.sqrt(n.value)*1.5,
|
||||
itemStyle:{color:n.shade,borderWidth:n.hub?2:0,borderColor:INK},
|
||||
label:{show:n.hub,position:'inside',
|
||||
color:INK,fontFamily:'Inter',fontSize:9.5,fontWeight:800},
|
||||
})),
|
||||
links,
|
||||
emphasis:{
|
||||
focus:'adjacency',
|
||||
lineStyle:{color:PAPER,opacity:.9,width:1.4},
|
||||
label:{show:true,color:PAPER,position:'right'},
|
||||
},
|
||||
blur:{itemStyle:{opacity:.1},lineStyle:{opacity:.03}},
|
||||
}],
|
||||
};
|
||||
const play=()=>{g.clear();g.setOption(opt)};
|
||||
play();
|
||||
// click on empty canvas replays; clicks on nodes are for dragging
|
||||
g.getZr().on('click',e=>{if(!e.target)play()});
|
||||
addEventListener('resize',()=>g.resize());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,164 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Mono — Thread Triptych, Interactive</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--bg:#F0EFEB;--dark:#1C1C1A;--paper:#F0EFEB}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);font-family:'Inter',sans-serif;padding:40px;-webkit-font-smoothing:antialiased;
|
||||
display:flex;justify-content:center}
|
||||
.card{background:var(--dark);color:var(--paper);border-radius:24px;padding:34px 34px 24px;
|
||||
width:min(1200px,100%)}
|
||||
h2{font-weight:700;font-size:19px;letter-spacing:-.02em;margin-bottom:4px}
|
||||
.sub{font-size:12px;color:#8F8E88;margin-bottom:6px}
|
||||
.src{font-size:9.5px;color:#55554F;margin-top:12px;letter-spacing:.08em;font-weight:500}
|
||||
#ch{width:100%;height:74vh;min-height:560px;display:block}
|
||||
#status{height:34px;display:flex;align-items:center;gap:10px;
|
||||
font-size:12px;color:#8F8E88;border-top:1px solid #2E2D29;margin-top:6px;padding-top:8px}
|
||||
#status b{color:var(--paper);font-weight:700}
|
||||
#status .pin{font-size:9px;letter-spacing:.1em;color:#55554F;font-weight:700}
|
||||
svg text{font-family:'Inter',sans-serif;user-select:none}
|
||||
|
||||
/* thread states */
|
||||
.thread{transition:opacity .18s ease,stroke-width .18s ease}
|
||||
.thread.hot{opacity:.95 !important}
|
||||
svg.focused .thread:not(.hot){opacity:.035 !important}
|
||||
.nodelab{cursor:pointer;transition:opacity .18s ease}
|
||||
svg.focused .nodelab:not(.hot){opacity:.25}
|
||||
.hit{cursor:pointer}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>Where the data actually goes — now answerable</h2>
|
||||
<div class="sub">source → processor → destination · one thread per data path · hover to trace a route · hover any label to pull its whole bundle · click to pin, click dark space to release</div>
|
||||
<svg id="ch" viewBox="0 0 1140 640" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div id="status"><span id="stext">hover a thread or a label</span><span class="pin" id="spin"></span></div>
|
||||
<div class="src">THREAD TRIPTYCH · INTERACTIVE · DATA MAP</div>
|
||||
</div>
|
||||
<script>
|
||||
const PAPER='#F0EFEB',NS='http://www.w3.org/2000/svg';
|
||||
const el=(p,t,a)=>{const n=document.createElementNS(NS,t);for(const k in a)n.setAttribute(k,a[k]);p.appendChild(n);return n};
|
||||
const txt=(p,a,s)=>{const n=el(p,'text',a);n.textContent=s;return n};
|
||||
const rnd=(i,k)=>Math.abs(((i*73856093)^(k*19349663))%1000)/1000;
|
||||
|
||||
// ── the pipeline ──
|
||||
const SRC=['web-app','ios-app','android','desktop','cli','public-api','webhooks','forms','email-in','chat-widget',
|
||||
'billing-ev','auth-ev','search-log','audit-ev','crash-rep','perf-beacon','cdn-log','edge-log','job-ev','sync-ev',
|
||||
'import','export-req','admin-ui','partner-api','iot-ping','backfill'];
|
||||
const MIDV=['parse','validate','dedupe','enrich','geoip','sessionize','pii-mask','score','sample','route',
|
||||
'batch','window','join','flatten','filter','hash','encrypt','compact','index','tag'];
|
||||
const MID=MIDV.flatMap(v=>[v+'-a',v+'-b']); // 40 processors
|
||||
const DST=['WAREHOUSE','ANALYTICS','CRM','ADS','BILLING','SEARCH','ML PIPE','ARCHIVE','AUDIT','EXPORTS'];
|
||||
|
||||
// routes: each source emits 2-3 full paths; volume is long-tailed
|
||||
const routes=[];
|
||||
SRC.forEach((s,i)=>{
|
||||
const n=2+Math.floor(rnd(i+1,5)*2);
|
||||
for(let k=0;k<n;k++){
|
||||
const mi=Math.floor(rnd(i*3+k+2,13)*MID.length);
|
||||
const di=Math.min(9,Math.floor(rnd(i*5+k+4,19)**2*11)); // skew: low dsts hoard
|
||||
const v=Math.round(1+rnd(i*7+k+6,29)**2*30); // 1..31, long tail
|
||||
routes.push({s:i,m:mi,d:di,v});
|
||||
}
|
||||
});
|
||||
|
||||
// ── geometry ──
|
||||
const X1=180,X2=560,X3=930;
|
||||
const sy=i=>36+i*22.6, my=i=>30+i*14.6, dy=i=>60+i*57;
|
||||
const path=r=>`M${X1+6} ${sy(r.s)} C${(X1+X2)/2} ${sy(r.s)} ${(X1+X2)/2} ${my(r.m)} ${X2} ${my(r.m)}`
|
||||
+` C${(X2+X3)/2} ${my(r.m)} ${(X2+X3)/2} ${dy(r.d)} ${X3-6} ${dy(r.d)}`;
|
||||
|
||||
const svg=document.getElementById('ch');
|
||||
const stext=document.getElementById('stext'),spin=document.getElementById('spin');
|
||||
const fmt=v=>v+'k ev/day';
|
||||
let pinned=null;
|
||||
|
||||
// column headers
|
||||
[[X1,'① SOURCE'],[X2,'② PROCESSOR'],[X3,'③ DESTINATION']].forEach(([x,t])=>
|
||||
txt(svg,{x,y:14,'font-size':9,'font-weight':800,fill:PAPER,'letter-spacing':'.14em','text-anchor':'middle'},t));
|
||||
|
||||
// threads (drawn first, under labels)
|
||||
const gThreads=el(svg,'g',{});
|
||||
routes.forEach((r,ri)=>{
|
||||
r.el=el(gThreads,'path',{d:path(r),fill:'none',stroke:PAPER,
|
||||
'stroke-width':Math.max(.6,r.v*.14),class:'thread',
|
||||
opacity:.06+Math.min(.2,r.v*.012),'stroke-linecap':'round'});
|
||||
// invisible fat twin: the hover/click target
|
||||
r.hit=el(gThreads,'path',{d:path(r),fill:'none',stroke:'#000','stroke-opacity':0,
|
||||
'stroke-width':9,class:'hit'});
|
||||
r.hit.dataset.route=ri;
|
||||
});
|
||||
|
||||
// node labels + ticks; each carries hit metadata
|
||||
const mkNode=(x,y,label,anchor,cls,meta,size,fill)=>{
|
||||
const g=el(svg,'g',{class:'nodelab'});
|
||||
g.dataset.kind=meta.kind;g.dataset.idx=meta.idx;
|
||||
el(g,'rect',{x:x-3,y:y-1.6,width:6,height:3.2,fill:PAPER,opacity:.85});
|
||||
txt(g,{x:anchor==='end'?x-9:x+9,y:y+2.6,'font-size':size,'font-weight':600,
|
||||
fill,'text-anchor':anchor},label);
|
||||
// generous invisible hit zone
|
||||
el(g,'rect',{x:anchor==='end'?x-92:x-6,y:y-6.5,width:98,height:13,fill:'#000','fill-opacity':0,class:'hit'});
|
||||
return g;
|
||||
};
|
||||
SRC.forEach((s,i)=>mkNode(X1,sy(i),s,'end','',{kind:'s',idx:i},7.5,'#B3B0A4'));
|
||||
MID.forEach((m,i)=>mkNode(X2,my(i),m,'start','',{kind:'m',idx:i},5.4,'#6A6963'));
|
||||
DST.forEach((d,i)=>{
|
||||
const g=el(svg,'g',{class:'nodelab'});
|
||||
g.dataset.kind='d';g.dataset.idx=i;
|
||||
el(g,'circle',{cx:X3,cy:dy(i),r:3.4,fill:PAPER});
|
||||
txt(g,{x:X3+12,y:dy(i)+3,'font-size':8.5,'font-weight':700,fill:'#B3B0A4','letter-spacing':'.08em'},d);
|
||||
el(g,'rect',{x:X3-8,y:dy(i)-10,width:120,height:20,fill:'#000','fill-opacity':0,class:'hit'});
|
||||
});
|
||||
|
||||
// ── highlight machinery ──
|
||||
const nodeLabs=[...svg.querySelectorAll('.nodelab')];
|
||||
const clear=()=>{
|
||||
svg.classList.remove('focused');
|
||||
routes.forEach(r=>r.el.classList.remove('hot'));
|
||||
nodeLabs.forEach(n=>n.classList.remove('hot'));
|
||||
stext.textContent='hover a thread or a label';spin.textContent='';
|
||||
};
|
||||
const focusRoutes=(rs,label)=>{
|
||||
svg.classList.add('focused');
|
||||
routes.forEach(r=>r.el.classList.toggle('hot',rs.includes(r)));
|
||||
nodeLabs.forEach(n=>{
|
||||
const k=n.dataset.kind,ix=+n.dataset.idx;
|
||||
n.classList.toggle('hot',rs.some(r=>(k==='s'&&r.s===ix)||(k==='m'&&r.m===ix)||(k==='d'&&r.d===ix)));
|
||||
});
|
||||
stext.innerHTML=label;
|
||||
};
|
||||
const routeLabel=r=>`<b>${SRC[r.s]}</b> → ${MID[r.m]} → <b>${DST[r.d]}</b> · ${fmt(r.v)}`;
|
||||
const nodeSel=(kind,idx)=>{
|
||||
const rs=routes.filter(r=>(kind==='s'&&r.s===idx)||(kind==='m'&&r.m===idx)||(kind==='d'&&r.d===idx));
|
||||
const total=rs.reduce((a,r)=>a+r.v,0);
|
||||
const name=kind==='s'?SRC[idx]:kind==='m'?MID[idx]:DST[idx];
|
||||
return [rs,`<b>${name}</b> · ${rs.length} path${rs.length>1?'s':''} · ${fmt(total)} total`];
|
||||
};
|
||||
|
||||
const apply=t=>{
|
||||
const hit=t.closest?.('.hit');
|
||||
if(!hit)return null;
|
||||
const rg=hit.dataset.route,ng=hit.closest('.nodelab');
|
||||
if(rg!=null){const r=routes[+rg];return()=>focusRoutes([r],routeLabel(r))}
|
||||
if(ng){const [rs,lab]=nodeSel(ng.dataset.kind,+ng.dataset.idx);return()=>focusRoutes(rs,lab)}
|
||||
return null;
|
||||
};
|
||||
svg.addEventListener('mousemove',e=>{
|
||||
if(pinned)return;
|
||||
const f=apply(e.target);
|
||||
f?f():clear();
|
||||
});
|
||||
svg.addEventListener('mouseleave',()=>{if(!pinned)clear()});
|
||||
svg.addEventListener('click',e=>{
|
||||
const f=apply(e.target);
|
||||
if(f){pinned=f;f();spin.textContent='PINNED · CLICK DARK SPACE TO RELEASE'}
|
||||
else{pinned=null;clear()}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
# 彩色换肤样张
|
||||
|
||||
**这里只查配色,不查结构。**
|
||||
|
||||
渲染代码、几何、动画的正本永远是 `templates/` 根目录那四个 gallery。彩色样张是它们的换肤版——逐节点对应同一份数据和动效,从这里照抄结构等于绕远路,而且容易漂。
|
||||
|
||||
内置预设的色值正本是 `color-presets.js`。样张 header 里那些散落的说明(加墨系数、色板顺序)只是自述,规则本体在 `SKILL.md` 第六点五节。Agent 可以根据数据结构和使用场景自动选择一套预设,不要求用户先触发彩色。同一份 HTML 或同一组交付只能使用一种色彩系统,禁止跨预设混用;用户拿到成品后可以继续调色,但要重新检查对比度、视觉主次和数据语义。
|
||||
|
||||
## Custom 不是第四套内置预设
|
||||
|
||||
用户明确提供品牌色、hex 或完整色板时,可以按 `SKILL.md` 的角色规则建立一套交付内 `CUSTOM`。它不出现在这里,因为不存在一套对所有用户都成立的 custom 样张。Custom 不得从三套内置预设借色补齐,也不得回写 `color-presets.js`,除非用户明确要求把品牌色固化为项目级预设。
|
||||
|
||||
Treemap 的四种实现已经加入 Basics gallery:Mono 靠父级标题带和留白分组;Porcelain 用明度对应有序父组;Palm 用色相对应顶层类目;Wire 只给一个叶子使用荧光橙。
|
||||
|
||||
## 三套
|
||||
|
||||
| 文件 | 色系 | 色彩逻辑 | 适配 |
|
||||
| - | - | - | - |
|
||||
| `*-porcelain.html` | 青瓷蓝 | 单色相明度阶 | 有序数据、单序列 |
|
||||
| `*-palm.html` | 椰林绿 | 色相 = 类目 | 无序类目 ≤4(5–6 勉强) |
|
||||
| `*-wire.html` | 编辑部红 | 灰阶 + 一个荧光橙 | 几乎所有场合,要一个视线落点 |
|
||||
|
||||
每套四个 gallery(basics / glance / lupi / maps),共 12 个,没有缺口。地图样张只在用户明确要求地图时才用得上。
|
||||
|
||||
archive(陶土青灰)已归档,不再是候选。它和 palm 都是"色相=类目",功能重叠。
|
||||
|
||||
## 三个交互大图
|
||||
|
||||
`big-circular` / `big-force` / `big-threads` 只有 porcelain 和 palm 两套,没有 wire。
|
||||
|
||||
原因:wire 本身就是黑灰阶加一个荧光橙,和原版 mono 大图的差别只在强调色,做出来近乎重复。要 wire 风格的大图,直接用 `templates/` 根目录的原版。
|
||||
|
||||
这三张是**暗底图**,色彩逻辑和浅卡不同:卡底压深,8 档单色相阶从亮到暗,编码的是"离核心多远"(亮 = 核心),不是类目。色值在 `color-presets.js` 各套的 `DARK` 块里。
|
||||
|
||||
为什么不用色相分类目:这三张分别需要 5 / 8 / 三列结构的档位,超过 palm 绿黄区间的容量上限(≤6)。按 SKILL.md 门一的规则,只能走单色相明度阶。
|
||||
|
||||
`big-threads` 的 hover 高亮是改 opacity(线恒为 `DK8[0]`),不用 `DARK.HL`。
|
||||
@@ -0,0 +1,901 @@
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Lieflat Basics F1–F17 × PALM</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@6/dist/echarts.min.js"></script>
|
||||
<style>
|
||||
:root{--bg:#F0EFEB;--ink:#58402E;--muted:rgba(88,64,46,.60);--faint:rgba(88,64,46,.32);--grid:rgba(88,64,46,.16)}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);font-family:'Inter','Noto Sans SC',sans-serif;color:var(--ink);padding:40px;-webkit-font-smoothing:antialiased}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:22px;max-width:1100px;margin:0 auto}
|
||||
.card{background:var(--bg);border-radius:24px;padding:28px 28px 20px}
|
||||
.card.wide{grid-column:1/-1}
|
||||
h2{font-weight:700;font-size:16.5px;letter-spacing:-.02em;margin-bottom:3px}
|
||||
.sub{font-size:11.5px;color:var(--muted);margin-bottom:14px}
|
||||
.src{font-size:9.5px;color:var(--faint);margin-top:10px;letter-spacing:.08em;font-weight:500}
|
||||
.badge{display:inline-block;font-size:9px;font-weight:700;letter-spacing:.08em;
|
||||
padding:3px 10px;border-radius:99px;margin-bottom:10px;border:1px dashed var(--faint);color:var(--muted)}
|
||||
svg{width:100%;max-height:330px;display:block;margin:0 auto}
|
||||
.chartbox{width:100%;height:360px}
|
||||
svg text{font-family:'Inter',sans-serif}
|
||||
.pop{transform-box:fill-box;transform-origin:center;animation:pop .5s cubic-bezier(.2,.7,.3,1.3) both}
|
||||
@keyframes pop{from{transform:scale(0)}to{transform:none}}
|
||||
.fade{animation:fade .9s ease both}
|
||||
@keyframes fade{from{opacity:0}}
|
||||
.draw{stroke-dasharray:1;stroke-dashoffset:1;animation:draw 1s cubic-bezier(.4,0,.2,1) both}
|
||||
@keyframes draw{to{stroke-dashoffset:0}}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.pop,.fade{animation:none}
|
||||
.draw{animation:none;stroke-dasharray:none;stroke-dashoffset:0}
|
||||
}
|
||||
.pagehead{max-width:1100px;margin:0 auto 26px;padding:0 4px}
|
||||
.pagehead h1{font-size:22px;letter-spacing:-.02em;font-weight:800}
|
||||
.pagehead p{font-size:12px;color:var(--muted);margin-top:5px;line-height:1.7}
|
||||
.chip{display:inline-block;width:12px;height:12px;border-radius:99px;margin:0 3px -1px 0;box-shadow:inset 0 0 0 1px rgba(0,0,0,.08)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="pagehead">
|
||||
<h1>基础型组 F1–F17 × PALM</h1>
|
||||
<p><span class="chip" style="background:#43593B"></span><span class="chip" style="background:#F2D17E"></span><span class="chip" style="background:#ACAD79"></span><span class="chip" style="background:#58402E"></span> 椰林 · 深绿主墨 + 浓咖文字,琥珀只干强调的活(峰值/最优/表针)。序数梯纯走绿→黄:深绿 → 中绿 → 苔绿 → 浅苔 → 橄榄 → 麦黄。几何、动画、数据与 basics-gallery 一致;彩色版统一加墨(线宽 ×1.8 / 透明度地板 .85,dot heat 除外——它的墨本来就够)。滚入播动画,点击重播。</p>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 柱状</span>
|
||||
<h2>Revenue by plan, rung by rung</h2>
|
||||
<div class="sub">one rung = one $k of MRR · the bar is a ladder you can count</div>
|
||||
<svg id="rungs" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">RUNG BARS · PALM · BILLING</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 折线</span>
|
||||
<h2>Thirty days of sign-ups</h2>
|
||||
<div class="sub">one dot = one day · hollow = weekend · the barcode floor keeps the calendar honest</div>
|
||||
<svg id="dayline" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">HAIRLINE LINE · PALM · GROWTH</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 面积</span>
|
||||
<h2>Concurrent users, filled with days</h2>
|
||||
<div class="sub">one hairline = one day, floor to peak · the area is made of days, not paint</div>
|
||||
<svg id="hairarea" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">HAIRLINE AREA · PALM · LIVE OPS</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 环形</span>
|
||||
<h2>Where the traffic comes from</h2>
|
||||
<div class="sub">one tick = one percent · four sources shade the dial · read it like a clock</div>
|
||||
<svg id="tickdonut" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">TICK DONUT · PALM · ACQUISITION</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 横向条形</span>
|
||||
<h2>Six teams, shipped and counted</h2>
|
||||
<div class="sub">one tick = one release this quarter · the row is a queue, not a bar</div>
|
||||
<svg id="tickrows" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">TICK ROWS · PALM · RELEASE LOG</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 分组柱</span>
|
||||
<h2>This year against last, plan by plan</h2>
|
||||
<div class="sub">two ladders per plan · solid rungs = 2026 · faint rungs = 2025 · one rung = $1k</div>
|
||||
<svg id="pairrungs" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">PAIRED RUNGS · PALM · BILLING YoY</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 堆叠柱</span>
|
||||
<h2>Where each region's revenue sits</h2>
|
||||
<div class="sub">one rung = $1k · three products stack up the ladder, strongest carries the most</div>
|
||||
<svg id="stackrungs" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">STACKED RUNGS · PALM · REVENUE MIX</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 散点</span>
|
||||
<h2>Price against satisfaction, twelve products</h2>
|
||||
<div class="sub">every dot hangs a plumb line to the floor · read x by the line, y by the height</div>
|
||||
<svg id="plumb" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">PLUMB SCATTER · PALM · PRODUCT PANEL</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 瀑布</span>
|
||||
<h2>From gross to net, step by step</h2>
|
||||
<div class="sub">one rung = $1k · solid rungs add, dashed rungs take away · the ladder walks down</div>
|
||||
<svg id="fall" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">RUNG WATERFALL · PALM · P&L</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 热力</span>
|
||||
<h2>When support gets loud</h2>
|
||||
<div class="sub">7 days × 12 hours · dot area = tickets · biggest cell labeled · silence stays visible</div>
|
||||
<svg id="dotheat" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">DOT HEAT · PALM · SUPPORT DESK</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 进度</span>
|
||||
<h2>How far to the quarter's goal</h2>
|
||||
<div class="sub">one tick = one percent of target · inked = earned · the dial reads left to right</div>
|
||||
<svg id="gauge" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">TICK GAUGE · PALM · QUARTERLY TARGET</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 哑铃对比</span>
|
||||
<h2>Onboarding, before and after the redesign</h2>
|
||||
<div class="sub">hollow dot = before · solid dot = after · beads on the track = minutes saved, countable</div>
|
||||
<svg id="dumbbell" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">DUMBBELL QUEUE · PALM · UX TIMING</div>
|
||||
</div>
|
||||
|
||||
<div class="card wide">
|
||||
<span class="badge">LUPI 基础型 · 矩形树图</span>
|
||||
<h2>Where the work went</h2>
|
||||
<div class="sub">1,280 shipped hours · area = effort · hue = top-level product family</div>
|
||||
<div id="treemap" class="chartbox" role="img" aria-label="Palm treemap of shipped hours nested by product family"></div>
|
||||
<div class="src">NESTED TREEMAP · PALM · WORK PORTFOLIO</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- F14 · rung histogram -->
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 直方图</span>
|
||||
<h2>Most tickets resolve within six hours</h2>
|
||||
<div class="sub">one rung = one ticket in a hundred · bins of two hours · the ladder shows the pile</div>
|
||||
<svg id="histo" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">RUNG HISTOGRAM · PALM · SUPPORT DESK</div>
|
||||
</div>
|
||||
|
||||
<!-- F15 · tick box -->
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 箱线图</span>
|
||||
<h2>Reply times, boxed by plan</h2>
|
||||
<div class="sub">box = the middle half of tickets · paper tick = median · hollow dot = an outlier nobody enjoyed</div>
|
||||
<svg id="boxplot" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">TICK BOX · PALM · SUPPORT DESK</div>
|
||||
</div>
|
||||
|
||||
<!-- F16 · stream ribbon -->
|
||||
<div class="card wide">
|
||||
<span class="badge">LUPI 基础型 · STREAMGRAPH</span>
|
||||
<h2>Three products trade the same river</h2>
|
||||
<div class="sub">48 weeks of usage share · band width = weekly active accounts · the river swells where the total grows</div>
|
||||
<svg id="stream" viewBox="0 0 840 300" preserveAspectRatio="xMidYMid meet" style="max-height:300px"></svg>
|
||||
<div class="src">STREAM RIBBON · PALM · PRODUCT ANALYTICS</div>
|
||||
</div>
|
||||
|
||||
<!-- F17 · candlestick -->
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · K 线</span>
|
||||
<h2>Six weeks of the token, candle by candle</h2>
|
||||
<div class="sub">body = open to close · wick = the day's full reach · green = closed down · wheat = closed up</div>
|
||||
<svg id="candle" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">CANDLESTICK · PALM · MARKET DATA</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
// ── PALM 角色 token(结构灰一律为文字色的透明档,不引入新色相)──
|
||||
const BG='#F0EFEB',TXT='#58402E',MUT='rgba(88,64,46,.60)',LAB='rgba(88,64,46,.72)',FAINT='rgba(88,64,46,.32)';
|
||||
const FLOOR='rgba(88,64,46,.24)',QUIET='rgba(88,64,46,.15)',TRACK='rgba(88,64,46,.12)',GRID='rgba(88,64,46,.16)';
|
||||
const DATA='#43593B',DATA2='#58402E',HERO='#D4A017';
|
||||
const FAINTDATA='#ACAD79',BEAD='#77835A';
|
||||
// 序数梯(明度∝数值,大→小 = 深→浅):给排序过的类目用
|
||||
const RAMP6=['#43593B','#5A7049','#77835A','#929960','#ACAD79','#F2D17E'];
|
||||
// 分段色:月份 / 进度区段等图内既有维度
|
||||
const SEG3=['#43593B','#77835A','#ACAD79'], ZONE3=['#ACAD79','#929960','#43593B'];
|
||||
const CAT4=["#43593B", "#77835A", "#ACAD79", "#F2D17E"],CAT3=["#43593B", "#929960", "#F2D17E"],CAT3L=["#43593B", "#77835A", "#ACAD79"],HEAT=["#43593B", "#929960", "#F2D17E"];
|
||||
const HEROMODE=false; // 黑体红睛模式:每张图一个主角元素整体走 HERO
|
||||
const NS='http://www.w3.org/2000/svg';
|
||||
const el=(p,t,a)=>{const n=document.createElementNS(NS,t);for(const k in a)n.setAttribute(k,a[k]);p.appendChild(n);return n};
|
||||
const txt=(p,a,s)=>{const n=el(p,'text',a);n.textContent=s;return n};
|
||||
const tip=(n,s)=>{const t=document.createElementNS(NS,'title');t.textContent=s;n.appendChild(t)};
|
||||
const rnd=(i,k)=>Math.abs(((i*73856093)^(k*19349663))%1000)/1000;
|
||||
const D2R=Math.PI/180;
|
||||
const pol=(cx,cy,r,deg)=>[cx+r*Math.cos(deg*D2R),cy+r*Math.sin(deg*D2R)];
|
||||
const obsReveal=(id,fn)=>{
|
||||
const n=document.getElementById(id);
|
||||
const go=()=>{n.innerHTML='';fn(n)};
|
||||
const io=new IntersectionObserver(es=>{if(es[0].isIntersecting){go();io.disconnect()}},{threshold:.3});
|
||||
io.observe(n);
|
||||
n.style.cursor='pointer';
|
||||
n.addEventListener('click',go);
|
||||
};
|
||||
|
||||
// ════ B1 · rung bars ════
|
||||
(()=>{
|
||||
const D=[['FREE',38],['STARTER',27],['PRO',22],['TEAM',16],['SCALE',11],['ENT',7]];
|
||||
obsReveal('rungs',s=>{
|
||||
const x0=i=>56+i*56,base=266,step=5.6,HW=14;
|
||||
D.forEach(([name,v],i)=>{
|
||||
const x=x0(i);
|
||||
const colInk=(HEROMODE&&i===0)?HERO:RAMP6[i];
|
||||
for(let k=0;k<v;k++){
|
||||
const y=base-k*step,w=HW-1.5+rnd(k+1,i+2)*3;
|
||||
el(s,'line',{x1:x-w,y1:y,x2:x+w,y2:y,stroke:colInk,'stroke-width':1.8,
|
||||
opacity:.85+rnd(k+2,i+4)*.15,class:'fade',style:`animation-delay:${i*.08+k*.012}s`});
|
||||
if(k%5===4)el(s,'circle',{cx:x+HW+4.5,cy:y,r:.8,fill:FAINT,
|
||||
class:'fade',style:`animation-delay:${i*.08+k*.012}s`});
|
||||
}
|
||||
const topY=base-(v-1)*step;
|
||||
const num=txt(s,{x,y:topY-10,'font-size':11,'font-weight':800,fill:TXT,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${.4+i*.08}s`},v);
|
||||
tip(num,`${name} — $${v}k MRR`);
|
||||
txt(s,{x,y:base+18,'font-size':7.5,'font-weight':700,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.08}s`},name);
|
||||
});
|
||||
el(s,'line',{x1:28,y1:base+4,x2:372,y2:base+4,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:.9s'},
|
||||
'ONE RUNG = $1K · SHADE = RANK · DOT MARKS EVERY FIFTH');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ B2 · hairline line ════
|
||||
(()=>{
|
||||
const N=30;
|
||||
const val=d=>46+22*Math.sin(d/4.6)+14*Math.sin(d/2.1)+rnd(d+1,5)*12;
|
||||
obsReveal('dayline',s=>{
|
||||
const x=d=>30+d*11.7,base=262,map=v=>base-v*2.15;
|
||||
for(let d=0;d<N;d++)
|
||||
el(s,'line',{x1:x(d),y1:base,x2:x(d),y2:base-7,stroke:FLOOR,'stroke-width':.6,
|
||||
class:'fade',style:`animation-delay:${d*.008}s`});
|
||||
el(s,'line',{x1:24,y1:base,x2:376,y2:base,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
const vs=Array.from({length:N},(_,d)=>val(d));
|
||||
const top=[];
|
||||
for(const d of [...vs.keys()].sort((a,b)=>vs[b]-vs[a])){
|
||||
if(top.every(t=>Math.abs(t-d)>=5))top.push(d);
|
||||
if(top.length===2)break;
|
||||
}
|
||||
const pts=vs.map((v,d)=>`${x(d)} ${map(v)}`).join(' L ');
|
||||
el(s,'path',{d:'M'+pts,fill:'none',stroke:DATA,'stroke-width':2.2,pathLength:1,
|
||||
class:'draw',style:'animation-duration:1.2s'});
|
||||
vs.forEach((v,d)=>{
|
||||
const weekend=d%7===5||d%7===6,big=top.includes(d);
|
||||
const dot=el(s,'circle',{cx:x(d),cy:map(v),r:big?5.2:3.1,
|
||||
fill:weekend?BG:(big?HERO:DATA),stroke:big?HERO:DATA,'stroke-width':weekend?1.4:0,
|
||||
class:'pop',style:`animation-delay:${.2+d*.03}s`});
|
||||
tip(dot,`Day ${d+1} — ${Math.round(v)} sign-ups`);
|
||||
if(big)txt(s,{x:x(d),y:map(v)-11,'font-size':9.5,'font-weight':800,fill:TXT,'text-anchor':'middle',
|
||||
style:`paint-order:stroke;stroke:${BG};stroke-width:3px;animation-delay:${1+d*.01}s`,
|
||||
class:'fade'},Math.round(v));
|
||||
});
|
||||
[[0,'JUN 1'],[14,'JUN 15'],[29,'JUN 30']].forEach(([d,m])=>
|
||||
txt(s,{x:x(d),y:base+18,'font-size':7.5,'font-weight':600,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade'},m));
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
|
||||
'ONE DOT = ONE DAY · HOLLOW = WEEKEND');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ B3 · hairline area ════
|
||||
(()=>{
|
||||
const N=45;
|
||||
const val=d=>34+26*Math.sin(d/7.2)+12*Math.sin(d/2.8)+rnd(d+1,3)*16;
|
||||
obsReveal('hairarea',s=>{
|
||||
const x=d=>28+d*7.75,base=262,map=v=>base-v*2.35;
|
||||
el(s,'line',{x1:22,y1:base,x2:378,y2:base,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
const vs=Array.from({length:N},(_,d)=>val(d));
|
||||
const peak=vs.indexOf(Math.max(...vs));
|
||||
vs.forEach((v,d)=>{
|
||||
el(s,'line',{x1:x(d),y1:base,x2:x(d),y2:map(v),
|
||||
stroke:d===peak?HERO:SEG3[Math.min(2,Math.floor(d/15))],'stroke-width':d===peak?1.6:1,
|
||||
opacity:d===peak?1:.8+rnd(d+1,7)*.2,
|
||||
class:'fade',style:`animation-delay:${d*.014}s`});
|
||||
});
|
||||
const pts=vs.map((v,d)=>`${x(d)} ${map(v)}`).join(' L ');
|
||||
el(s,'path',{d:'M'+pts,fill:'none',stroke:DATA,'stroke-width':2,pathLength:1,
|
||||
class:'draw',style:'animation-delay:.4s;animation-duration:1.2s'});
|
||||
const pd=el(s,'circle',{cx:x(peak),cy:map(vs[peak]),r:4.2,fill:HERO,
|
||||
class:'pop',style:'animation-delay:1.2s'});
|
||||
tip(pd,`Day ${peak+1} — ${Math.round(vs[peak])}k peak`);
|
||||
txt(s,{x:x(peak),y:map(vs[peak])-11,'font-size':9.5,'font-weight':800,fill:TXT,'text-anchor':'middle',
|
||||
style:`paint-order:stroke;stroke:${BG};stroke-width:3px;animation-delay:1.3s`,
|
||||
class:'fade'},Math.round(vs[peak])+'k');
|
||||
[[0,'MAY'],[22,'JUN'],[44,'JUL']].forEach(([d,m])=>
|
||||
txt(s,{x:x(d),y:base+18,'font-size':7.5,'font-weight':600,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade'},m));
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.3s'},
|
||||
'ONE HAIRLINE = ONE DAY · SHADE = MONTH · FLOOR TO PEAK');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ B4 · tick donut ════
|
||||
(()=>{
|
||||
const D=[['ORGANIC',37,CAT4[0]],['PAID',28,CAT4[1]],['REFERRAL',21,CAT4[2]],['SOCIAL',14,CAT4[3]]];
|
||||
obsReveal('tickdonut',s=>{
|
||||
const cx=200,cy=148,R0=64;
|
||||
let k0=0;
|
||||
D.forEach(([name,v,shade],si)=>{
|
||||
for(let k=0;k<v;k++){
|
||||
const idx=k0+k,a=idx*3.6-90;
|
||||
const len=12+rnd(idx+1,si+2)*6;
|
||||
const [x1,y1]=pol(cx,cy,R0,a),[x2,y2]=pol(cx,cy,R0+len,a);
|
||||
el(s,'line',{x1,y1,x2,y2,stroke:shade,'stroke-width':1.9,
|
||||
class:'fade',style:`animation-delay:${idx*.012}s`});
|
||||
if(idx%10===0){
|
||||
const [dx,dy]=pol(cx,cy,R0-5,a);
|
||||
el(s,'circle',{cx:dx,cy:dy,r:.8,fill:FAINT,
|
||||
class:'fade',style:`animation-delay:${idx*.012}s`});
|
||||
}
|
||||
}
|
||||
const mid=(k0+v/2)*3.6-90,[lx,ly]=pol(cx,cy,R0+38,mid),[gx,gy]=pol(cx,cy,R0+20,mid);
|
||||
el(s,'line',{x1:gx,y1:gy,x2:lx,y2:ly,stroke:FAINT,'stroke-width':.7,
|
||||
'stroke-dasharray':'1 3',class:'fade',style:`animation-delay:${.6+si*.1}s`});
|
||||
const anchor=Math.cos(mid*D2R)>0.3?'start':Math.cos(mid*D2R)<-0.3?'end':'middle';
|
||||
const lab=txt(s,{x:lx,y:ly+3,'font-size':8,'font-weight':800,fill:shade,'text-anchor':anchor,
|
||||
'letter-spacing':'.06em',style:`paint-order:stroke;stroke:${BG};stroke-width:3px;animation-delay:${.65+si*.1}s`,
|
||||
class:'fade'},`${name} · ${v}`);
|
||||
tip(lab,`${name} — ${v}% of traffic`);
|
||||
k0+=v;
|
||||
});
|
||||
txt(s,{x:cx,y:cy-2,'font-size':22,'font-weight':800,fill:TXT,'text-anchor':'middle',
|
||||
class:'fade',style:'animation-delay:.9s'},'100');
|
||||
txt(s,{x:cx,y:cy+14,'font-size':7,'font-weight':600,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade',style:'animation-delay:.9s'},'TICKS · ONE = 1%');
|
||||
txt(s,{x:200,y:296,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
|
||||
'TWELVE O’CLOCK IS ZERO · DOT MARKS EVERY TENTH · READS CLOCKWISE');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C1 · tick rows ════
|
||||
(()=>{
|
||||
const D=[['PLATFORM',34],['GROWTH',28],['MOBILE',22],['INFRA',17],['ML',11],['DESIGN',8]];
|
||||
obsReveal('tickrows',s=>{
|
||||
const y0=i=>52+i*44,X0=104,PX=6.9;
|
||||
D.forEach(([name,v],i)=>{
|
||||
const y=y0(i);
|
||||
txt(s,{x:94,y:y+3,'font-size':8,'font-weight':700,fill:LAB,'text-anchor':'end',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.08}s`},name);
|
||||
el(s,'line',{x1:X0,y1:y+9,x2:X0+34*PX,y2:y+9,stroke:GRID,'stroke-width':.6,
|
||||
class:'fade',style:`animation-delay:${i*.08}s`});
|
||||
const rowInk=(HEROMODE&&i===0)?HERO:RAMP6[i];
|
||||
for(let k=0;k<v;k++){
|
||||
const x=X0+k*PX+PX/2,h=9+rnd(k+1,i+2)*6;
|
||||
el(s,'line',{x1:x,y1:y+9,x2:x,y2:y+9-h,stroke:rowInk,'stroke-width':1.7,
|
||||
opacity:.85+rnd(k+3,i+5)*.15,class:'fade',style:`animation-delay:${i*.08+k*.012}s`});
|
||||
if(k%5===4)el(s,'circle',{cx:x,cy:y+13,r:.8,fill:FAINT,
|
||||
class:'fade',style:`animation-delay:${i*.08+k*.012}s`});
|
||||
}
|
||||
const lab=txt(s,{x:X0+v*PX+10,y:y+4,'font-size':11,'font-weight':800,fill:TXT,
|
||||
class:'fade',style:`animation-delay:${.4+i*.08}s`},v);
|
||||
tip(lab,`${name} — ${v} releases`);
|
||||
});
|
||||
txt(s,{x:200,y:308,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:.9s'},
|
||||
'ONE TICK = ONE RELEASE · SHADE = RANK · DOT MARKS EVERY FIFTH');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C2 · paired rungs ════
|
||||
(()=>{
|
||||
const D=[['FREE',31,38],['STARTER',22,27],['PRO',16,22],['TEAM',13,16],['ENT',6,9]];
|
||||
obsReveal('pairrungs',s=>{
|
||||
const x0=i=>64+i*66,base=258,step=5.4,HW=10;
|
||||
D.forEach(([name,was,now],i)=>{
|
||||
const xa=x0(i)-13,xb=x0(i)+13;
|
||||
for(let k=0;k<was;k++){
|
||||
const y=base-k*step,w=HW-1.2+rnd(k+1,i+2)*2.4;
|
||||
el(s,'line',{x1:xa-w,y1:y,x2:xa+w,y2:y,stroke:FAINTDATA,'stroke-width':1.8,
|
||||
opacity:.75+rnd(k+2,i+3)*.25,class:'fade',style:`animation-delay:${i*.08+k*.01}s`});
|
||||
}
|
||||
for(let k=0;k<now;k++){
|
||||
const y=base-k*step,w=HW-1.2+rnd(k+1,i+7)*2.4;
|
||||
el(s,'line',{x1:xb-w,y1:y,x2:xb+w,y2:y,stroke:DATA,'stroke-width':1.8,
|
||||
opacity:.85+rnd(k+2,i+8)*.15,class:'fade',style:`animation-delay:${.15+i*.08+k*.01}s`});
|
||||
}
|
||||
const topB=base-(now-1)*step;
|
||||
const num=txt(s,{x:xb,y:topB-9,'font-size':10.5,'font-weight':800,fill:HEROMODE?HERO:TXT,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${.5+i*.08}s`},now);
|
||||
tip(num,`${name} — $${was}k → $${now}k`);
|
||||
txt(s,{x:xa,y:base-(was-1)*step-9,'font-size':8.5,'font-weight':700,fill:FAINTDATA,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${.5+i*.08}s`},was);
|
||||
txt(s,{x:x0(i),y:base+18,'font-size':7.5,'font-weight':700,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.08}s`},name);
|
||||
});
|
||||
el(s,'line',{x1:30,y1:base+4,x2:370,y2:base+4,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1s'},
|
||||
'FAINT = 2025 · SOLID = 2026 · ONE RUNG = $1K');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C3 · stacked rungs ════
|
||||
(()=>{
|
||||
const D=[['NA',[18,11,7]],['EU',[14,9,5]],['APAC',[9,7,6]],['LATAM',[5,4,2]]];
|
||||
const SEG=['CORE','ADD-ONS','SERVICES'];
|
||||
obsReveal('stackrungs',s=>{
|
||||
const x0=i=>72+i*76,base=262,step=5.2,HW=13;
|
||||
D.forEach(([name,segs],i)=>{
|
||||
const x=x0(i);let k0=0;
|
||||
segs.forEach((v,si)=>{
|
||||
for(let k=0;k<v;k++){
|
||||
const y=base-(k0+k+si)*step,w=HW-1.4+rnd(k+1,i*3+si+2)*2.8;
|
||||
el(s,'line',{x1:x-w,y1:y,x2:x+w,y2:y,stroke:CAT3[si],'stroke-width':1.8,
|
||||
opacity:.85+rnd(k+2,i+si+4)*.15,class:'fade',
|
||||
style:`animation-delay:${i*.09+(k0+k)*.012}s`});
|
||||
}
|
||||
const midY=base-(k0+v/2+si)*step;
|
||||
const lab=txt(s,{x:x+HW+7,y:midY+2.5,'font-size':8,'font-weight':800,fill:CAT3L[si],
|
||||
class:'fade',style:`animation-delay:${.5+i*.09+si*.06}s`},v);
|
||||
tip(lab,`${name} ${SEG[si]} — $${v}k`);
|
||||
k0+=v;
|
||||
});
|
||||
const total=segs[0]+segs[1]+segs[2];
|
||||
txt(s,{x,y:base-(k0+2)*step-8,'font-size':10.5,'font-weight':800,fill:TXT,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${.6+i*.09}s`},total);
|
||||
txt(s,{x,y:base+18,'font-size':7.5,'font-weight':700,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.09}s`},name);
|
||||
});
|
||||
el(s,'line',{x1:36,y1:base+4,x2:364,y2:base+4,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
|
||||
'STRONGEST = CORE · MID = ADD-ONS · PALE = SERVICES · ONE RUNG = $1K');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C4 · plumb scatter ════
|
||||
(()=>{
|
||||
const P=[['Editor',72,86],['Boards',58,74],['Docs',44,79],['Chat',38,62],['Flows',66,58],
|
||||
['Vault',82,71],['Pages',28,55],['Sync',52,49],['Grid',88,44],['Views',20,68],
|
||||
['Hub',76,32],['Forms',34,38]];
|
||||
obsReveal('plumb',s=>{
|
||||
const X0=48,X1=368,base=258,mapX=p=>X0+p/100*(X1-X0),mapY=v=>base-(v-20)*2.6;
|
||||
for(let g=0;g<=20;g++){
|
||||
const x=X0+g/20*(X1-X0);
|
||||
el(s,'line',{x1:x,y1:base,x2:x,y2:base-(g%5===0?7:4),stroke:FLOOR,'stroke-width':.6,
|
||||
class:'fade',style:`animation-delay:${g*.01}s`});
|
||||
}
|
||||
el(s,'line',{x1:X0-6,y1:base,x2:X1+6,y2:base,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
txt(s,{x:X0,y:base+16,'font-size':7,'font-weight':600,fill:FAINT,class:'fade'},'CHEAP');
|
||||
txt(s,{x:X1,y:base+16,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'end',
|
||||
class:'fade'},'PREMIUM');
|
||||
const best=P.reduce((a,b)=>b[2]>a[2]?b:a),worst=P.reduce((a,b)=>b[2]<a[2]?b:a);
|
||||
P.forEach(([name,px,sat],i)=>{
|
||||
const x=mapX(px),y=mapY(sat),hero=name===best[0]||name===worst[0];
|
||||
el(s,'line',{x1:x,y1:base,x2:x,y2:y,stroke:FAINTDATA,'stroke-width':1,
|
||||
opacity:.75,class:'fade',style:`animation-delay:${.2+i*.05}s`});
|
||||
const dot=el(s,'circle',{cx:x,cy:y,r:hero?5.4:3.4,fill:hero?HERO:DATA,
|
||||
class:'pop',style:`animation-delay:${.25+i*.05}s`});
|
||||
tip(dot,`${name} — price ${px} · satisfaction ${sat}`);
|
||||
if(hero)txt(s,{x,y:y-10,'font-size':8.5,'font-weight':800,fill:TXT,'text-anchor':'middle',
|
||||
style:`paint-order:stroke;stroke:${BG};stroke-width:3px;animation-delay:${.8}s`,
|
||||
class:'fade'},`${name} · ${sat}`);
|
||||
});
|
||||
txt(s,{x:26,y:mapY(85),'font-size':7,'font-weight':600,fill:FAINT,
|
||||
transform:`rotate(-90 26 ${mapY(85)})`,'text-anchor':'end','letter-spacing':'.08em',class:'fade'},'HAPPIER ↑');
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1s'},
|
||||
'EVERY DOT HANGS A PLUMB LINE · READ X AT THE FLOOR');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C5 · rung waterfall ════
|
||||
(()=>{
|
||||
const D=[['GROSS',42,null],['REFUNDS',-6,null],['COGS',-11,null],['OPS',-8,null],['NET',17,'total']];
|
||||
obsReveal('fall',s=>{
|
||||
const x0=i=>58+i*72,step=5.2,HW=11;
|
||||
let lv=0;const rows=[];
|
||||
D.forEach(([name,v],i)=>{
|
||||
if(name==='GROSS'){rows.push([name,v,0,v]);lv=v}
|
||||
else if(name==='NET'){rows.push([name,lv,0,lv])}
|
||||
else{rows.push([name,v,lv+v,lv]);lv=lv+v}
|
||||
});
|
||||
const base=252,yOf=k=>base-k*step;
|
||||
rows.forEach(([name,v,lo,hi],i)=>{
|
||||
const x=x0(i),isTotal=name==='GROSS'||name==='NET',neg=v<0&&!isTotal;
|
||||
const from=isTotal?0:lo,to=isTotal?v:hi,n=Math.abs(to-from);
|
||||
const colInk=name==='NET'?HERO:DATA; // NET 是这张图的结论,常驻强调色
|
||||
for(let k=0;k<n;k++){
|
||||
const y=yOf(from+k),w=HW-1.2+rnd(k+1,i+2)*2.4;
|
||||
if(neg)el(s,'line',{x1:x-w,y1:y,x2:x+w,y2:y,stroke:DATA2,'stroke-width':1.8,
|
||||
'stroke-dasharray':'2.5 2.5',opacity:.85,class:'fade',
|
||||
style:`animation-delay:${i*.12+k*.014}s`});
|
||||
else el(s,'line',{x1:x-w,y1:y,x2:x+w,y2:y,stroke:colInk,'stroke-width':1.8,
|
||||
opacity:.85+rnd(k+2,i+4)*.15,class:'fade',style:`animation-delay:${i*.12+k*.014}s`});
|
||||
}
|
||||
if(i<rows.length-1){
|
||||
const nx=x0(i+1);
|
||||
const lvl=isTotal?v:(neg?lo:hi);
|
||||
el(s,'line',{x1:x+HW+2,y1:yOf(lvl),x2:nx-HW-2,y2:yOf(lvl),stroke:FAINT,
|
||||
'stroke-width':.7,'stroke-dasharray':'2 3',class:'fade',
|
||||
style:`animation-delay:${.3+i*.12}s`});
|
||||
}
|
||||
const topY=yOf(Math.max(from,to));
|
||||
const num=txt(s,{x,y:topY-8,'font-size':10,'font-weight':800,
|
||||
fill:neg?DATA2:TXT,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${.4+i*.12}s`},(neg?'−':'')+Math.abs(v));
|
||||
tip(num,`${name} — ${neg?'−':''}$${Math.abs(v)}k`);
|
||||
txt(s,{x,y:base+18,'font-size':7.5,'font-weight':700,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.12}s`},name);
|
||||
});
|
||||
el(s,'line',{x1:30,y1:base+4,x2:370,y2:base+4,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
txt(s,{x:200,y:302,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
|
||||
'SOLID ADD · DASHED TAKE AWAY · NET IN ACCENT · ONE RUNG = $1K');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C6 · dot heat ════
|
||||
(()=>{
|
||||
const DAY=['MON','TUE','WED','THU','FRI','SAT','SUN'];
|
||||
obsReveal('dotheat',s=>{
|
||||
const x0=j=>64+j*27,y0=i=>58+i*30;
|
||||
let max=0,mi=0,mj=0;
|
||||
const v=(i,j)=>{
|
||||
const day=i<5?1:.32;
|
||||
const hour=Math.exp(-((j-4.6)**2)/7)+.7*Math.exp(-((j-8.4)**2)/5);
|
||||
const raw=day*hour*22*(0.6+rnd(i*12+j+1,j+3)*.8);
|
||||
return Math.round(raw);
|
||||
};
|
||||
for(let i=0;i<7;i++)for(let j=0;j<12;j++){const t=v(i,j);if(t>max){max=t;mi=i;mj=j}}
|
||||
for(let i=0;i<7;i++){
|
||||
txt(s,{x:50,y:y0(i)+3,'font-size':7.5,'font-weight':700,fill:LAB,'text-anchor':'end',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.05}s`},DAY[i]);
|
||||
for(let j=0;j<12;j++){
|
||||
const t=v(i,j),x=x0(j),y=y0(i);
|
||||
if(!t){el(s,'circle',{cx:x,cy:y,r:.8,fill:QUIET,
|
||||
class:'pop',style:`animation-delay:${i*.05+j*.015}s`});continue}
|
||||
const hero=i===mi&&j===mj;
|
||||
const dot=el(s,'circle',{cx:x,cy:y,r:1.2+Math.sqrt(t)*2.1,
|
||||
fill:t>max*.66?HEAT[0]:t>max*.33?HEAT[1]:HEAT[2],
|
||||
class:'pop',style:`animation-delay:${i*.05+j*.015}s`});
|
||||
tip(dot,`${DAY[i]} ${8+j}:00 — ${t} tickets`);
|
||||
if(hero)el(s,'circle',{cx:x,cy:y,r:1.2+Math.sqrt(t)*2.1+3.4,fill:'none',
|
||||
stroke:HERO,'stroke-width':1,'stroke-dasharray':'2 3',
|
||||
class:'fade',style:'animation-delay:1s'});
|
||||
}
|
||||
}
|
||||
for(let j=0;j<12;j+=2)
|
||||
txt(s,{x:x0(j),y:y0(6)+26,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${j*.02}s`},(8+j)+':00');
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
|
||||
`DOT AREA = TICKETS · DASHED RING = THE PEAK, ${max} · TINY DOT = A QUIET HOUR`);
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C7 · tick gauge ════
|
||||
(()=>{
|
||||
const GOAL=73;
|
||||
obsReveal('gauge',s=>{
|
||||
const cx=200,cy=190,R0=104,A0=-195,SW=210;
|
||||
for(let k=0;k<100;k++){
|
||||
const a=A0+k/100*SW,inked=k<GOAL;
|
||||
const len=inked?15+rnd(k+1,3)*6:5+rnd(k+1,7)*2.5;
|
||||
const [x1,y1]=pol(cx,cy,R0,a),[x2,y2]=pol(cx,cy,R0+len,a);
|
||||
el(s,'line',{x1,y1,x2,y2,stroke:inked?ZONE3[Math.min(2,Math.floor(k/25))]:FLOOR,'stroke-width':inked?1.9:.6,
|
||||
class:'fade',style:`animation-delay:${k*.012}s`});
|
||||
}
|
||||
[25,50,75,100].forEach(m=>{
|
||||
const a=A0+m/100*SW,[dx,dy]=pol(cx,cy,R0-7,a),[tx2,ty2]=pol(cx,cy,R0-19,a);
|
||||
el(s,'circle',{cx:dx,cy:dy,r:1,fill:FAINT,class:'fade',style:'animation-delay:.8s'});
|
||||
txt(s,{x:tx2,y:ty2+3,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
class:'fade',style:'animation-delay:.85s'},m);
|
||||
});
|
||||
const aT=A0+GOAL/100*SW,[ex,ey]=pol(cx,cy,R0+20,aT);
|
||||
el(s,'circle',{cx:ex,cy:ey,r:3.2,fill:HERO,class:'pop',style:'animation-delay:1.1s'});
|
||||
const num=txt(s,{x:cx,y:cy-4,'font-size':34,'font-weight':800,fill:TXT,'text-anchor':'middle',
|
||||
class:'fade',style:'animation-delay:1s'},GOAL+'%');
|
||||
tip(num,`$730k of the $1M quarter target`);
|
||||
txt(s,{x:cx,y:cy+16,'font-size':8,'font-weight':600,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade',style:'animation-delay:1.05s'},'27 TICKS TO GO');
|
||||
txt(s,{x:200,y:300,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
|
||||
'ONE TICK = 1% OF TARGET · SHADE = QUARTER · INKED = EARNED');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C8 · dumbbell queue ════
|
||||
(()=>{
|
||||
const D=[['INVITE FLOW',14,6],['FIRST BOARD',19,9],['IMPORT DATA',26,13],['TEAM SETUP',31,21],['GO LIVE',38,30]];
|
||||
obsReveal('dumbbell',s=>{
|
||||
const y0=i=>56+i*46,X0=118,X1=364,mapX=v=>X0+(v-4)/36*(X1-X0);
|
||||
D.forEach(([name,was,now],i)=>{
|
||||
const y=y0(i),xa=mapX(was),xb=mapX(now);
|
||||
txt(s,{x:108,y:y+3,'font-size':7.5,'font-weight':700,fill:LAB,'text-anchor':'end',
|
||||
'letter-spacing':'.06em',class:'fade',style:`animation-delay:${i*.08}s`},name);
|
||||
el(s,'line',{x1:X0-6,y1:y,x2:X1+6,y2:y,stroke:TRACK,'stroke-width':.7,
|
||||
class:'fade',style:`animation-delay:${i*.08}s`});
|
||||
const n=was-now;
|
||||
for(let k=0;k<n;k++){
|
||||
const t=(k+.5)/n,x=xb+t*(xa-xb),yy=y+(rnd(k+1,i+3)-.5)*2.6;
|
||||
el(s,'circle',{cx:x,cy:yy,r:2.1+rnd(k+2,i+4)*.9,fill:BEAD,opacity:.95,
|
||||
class:'pop',style:`animation-delay:${.3+i*.08+k*.03}s`});
|
||||
}
|
||||
el(s,'circle',{cx:xa,cy:y,r:4.8,fill:BG,stroke:DATA,'stroke-width':1.7,
|
||||
class:'pop',style:`animation-delay:${.2+i*.08}s`});
|
||||
const after=el(s,'circle',{cx:xb,cy:y,r:5.2,fill:HERO,
|
||||
class:'pop',style:`animation-delay:${.6+i*.08}s`});
|
||||
tip(after,`${name} — ${was} min → ${now} min`);
|
||||
txt(s,{x:xa+10,y:y-8,'font-size':8.5,'font-weight':700,fill:FAINTDATA,
|
||||
class:'fade',style:`animation-delay:${.3+i*.08}s`},was);
|
||||
txt(s,{x:xb-10,y:y-8,'font-size':10,'font-weight':800,fill:TXT,'text-anchor':'end',
|
||||
class:'fade',style:`animation-delay:${.7+i*.08}s`},now);
|
||||
});
|
||||
txt(s,{x:X0,y:290,'font-size':7,'font-weight':600,fill:FAINT,class:'fade'},'FASTER ←');
|
||||
txt(s,{x:X1,y:290,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'end',
|
||||
class:'fade'},'MINUTES');
|
||||
txt(s,{x:200,y:308,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1s'},
|
||||
'ONE BEAD = ONE MINUTE SAVED · HOLLOW = BEFORE · SOLID = AFTER');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C9 · nested treemap ════
|
||||
(()=>{
|
||||
const TOTAL=1280;
|
||||
const leaf=(name,value,color,labelColor=BG)=>({name,value,itemStyle:{color},label:{color:labelColor}});
|
||||
const TREE_DATA=[
|
||||
{name:'PRODUCT · 50%',children:[leaf('Core app',260,DATA),leaf('Collaboration',190,DATA),leaf('Search',110,DATA),leaf('Mobile',80,DATA)]},
|
||||
{name:'PLATFORM · 31%',children:[leaf('AI systems',170,BEAD),leaf('Data infra',120,BEAD),leaf('APIs',105,BEAD)]},
|
||||
{name:'GROWTH · 19%',children:[leaf('Acquisition',90,HERO,TXT),leaf('Retention',80,HERO,TXT),leaf('Onboarding',75,HERO,TXT)]},
|
||||
];
|
||||
const host=document.getElementById('treemap');let chart;
|
||||
const draw=()=>{chart=chart||echarts.init(host,null,{renderer:'svg'});chart.clear();chart.setOption({
|
||||
animationDuration:900,animationEasing:'quarticOut',aria:{enabled:true},
|
||||
tooltip:{backgroundColor:BG,borderColor:TXT,borderWidth:1,textStyle:{color:TXT,fontSize:12},
|
||||
formatter:i=>`${i.treePathInfo.slice(1).map(d=>d.name.replace(/ · .*$/,'')).join(' / ')}<br><b>${i.value} hours</b> · ${(i.value/TOTAL*100).toFixed(1)}%`},
|
||||
series:[{type:'treemap',data:TREE_DATA,top:0,right:0,bottom:0,left:0,roam:false,nodeClick:false,breadcrumb:{show:false},leafDepth:2,squareRatio:1.2,
|
||||
label:{show:true,position:'insideTopLeft',padding:[10,8],color:TXT,fontSize:12,fontWeight:700,lineHeight:18,
|
||||
formatter:p=>p.treePathInfo.length<=1?'':p.treePathInfo.length===2?p.name:`${p.name}\n${p.value} h`},
|
||||
upperLabel:{show:true,height:32,padding:[0,10],color:TXT,fontSize:10,fontWeight:700,backgroundColor:BG},
|
||||
itemStyle:{borderColor:BG,borderWidth:2,gapWidth:2},emphasis:{focus:'ancestor',itemStyle:{borderColor:TXT,borderWidth:1.5}},
|
||||
levels:[{itemStyle:{borderWidth:0,gapWidth:5}},{upperLabel:{show:true,height:32},itemStyle:{borderColor:BG,borderWidth:3,gapWidth:3}},{upperLabel:{show:false},itemStyle:{borderColor:BG,borderWidth:2,gapWidth:2}}]}]});};
|
||||
const io=new IntersectionObserver(es=>{if(es[0].isIntersecting){draw();io.disconnect()}},{threshold:.25});
|
||||
io.observe(host);host.style.cursor='pointer';host.addEventListener('click',draw);addEventListener('resize',()=>chart?.resize());
|
||||
})();
|
||||
|
||||
// ── New chart-family helpers (scoped to the added templates) ──
|
||||
(()=>{
|
||||
const INK='#43593B',PAPER='#F0EFEB',MUTED='rgba(88,64,46,.60)',GRID='rgba(88,64,46,.16)';
|
||||
const NS='http://www.w3.org/2000/svg';
|
||||
const el=(p,t,a)=>{const n=document.createElementNS(NS,t);for(const k in a)n.setAttribute(k,a[k]);p.appendChild(n);return n};
|
||||
const txt=(p,a,s)=>{const n=el(p,'text',a);n.textContent=s;return n};
|
||||
const tip=(n,s)=>{const t=document.createElementNS(NS,'title');t.textContent=s;n.appendChild(t)};
|
||||
const rnd=(i,k)=>Math.abs(((i*73856093)^(k*19349663))%1000)/1000;
|
||||
|
||||
// ════ F14 · rung histogram ════
|
||||
// 直方图的 Lupi Basics 皮:连续数值分箱,一箱一把梯子,1 档 = 1 张工单
|
||||
// (百分之一)。箱界写在梯脚之间——直方图的箱是区间不是类目,
|
||||
// 这是它和 F1 Rung Bars 的本质区别。峰值箱标数,中位数落点小旗标注。
|
||||
(()=>{
|
||||
const BIN=[6,14,22,19,13,9,6,4,3,2,1,1]; // 2h bins, sums to 100
|
||||
// 序数梯:箱越高绿越深(票堆的大小是序数维度,同 glance-palm 的 rampAt 逻辑)
|
||||
const BMAX=Math.max(...BIN);
|
||||
const bcol=v=>v>BMAX*.75?INK:v>BMAX*.45?'#77835A':v>BMAX*.2?'#929960':'#ACAD79';
|
||||
obsReveal('histo',s=>{
|
||||
const X0=44,PW=27.5,base=252,step=5.4,HW=10.5;
|
||||
BIN.forEach((v,i)=>{
|
||||
const x=X0+i*PW+PW/2;
|
||||
for(let k=0;k<v;k++){
|
||||
const y=base-k*step,w=HW-1.4+rnd(k+1,i+2)*2.8;
|
||||
el(s,'line',{x1:x-w,y1:y,x2:x+w,y2:y,stroke:bcol(v),'stroke-width':1.8,
|
||||
opacity:.68+rnd(k+2,i+4)*.32,class:'fade',style:`animation-delay:${i*.06+k*.012}s`});
|
||||
if(k%5===4)el(s,'circle',{cx:x+HW+3.5,cy:y,r:.7,fill:'rgba(88,64,46,.32)',
|
||||
class:'fade',style:`animation-delay:${i*.06+k*.012}s`});
|
||||
}
|
||||
});
|
||||
// peak label
|
||||
const pk=BIN.indexOf(Math.max(...BIN));
|
||||
const px=X0+pk*PW+PW/2;
|
||||
const num=txt(s,{x:px,y:base-(BIN[pk]-1)*step-10,'font-size':11,'font-weight':800,fill:INK,
|
||||
'text-anchor':'middle',class:'fade',style:'animation-delay:.8s'},BIN[pk]);
|
||||
tip(num,`${BIN[pk]} of 100 tickets took ${pk*2}–${pk*2+2}h`);
|
||||
// bin edges: the continuous axis is the histogram's identity
|
||||
el(s,'line',{x1:X0-4,y1:base+4,x2:X0+12*PW+4,y2:base+4,stroke:GRID,'stroke-width':1.44,class:'fade'});
|
||||
for(let e=0;e<=12;e++){
|
||||
const x=X0+e*PW;
|
||||
el(s,'line',{x1:x,y1:base+4,x2:x,y2:base+(e%2===0?11:8),stroke:'rgba(88,64,46,.24)','stroke-width':1.08,
|
||||
class:'fade',style:`animation-delay:${e*.02}s`});
|
||||
if(e%2===0)txt(s,{x,y:base+21,'font-size':7,'font-weight':600,fill:MUTED,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${e*.02}s`},e*2+'h');
|
||||
}
|
||||
// median flag: cumulative crosses 50 inside bin 2
|
||||
let acc=0,mb=0;for(let i=0;i<12;i++){acc+=BIN[i];if(acc>=50){mb=i;break}}
|
||||
const mx=X0+mb*PW+PW*.7;
|
||||
el(s,'line',{x1:mx,y1:base+4,x2:mx,y2:base-BIN[mb]*step-16,stroke:'#929960','stroke-width':1.62,
|
||||
'stroke-dasharray':'2 4',class:'fade',style:'animation-delay:1s'});
|
||||
txt(s,{x:mx+4,y:base-BIN[mb]*step-20,'font-size':7.5,'font-weight':800,fill:'rgba(88,64,46,.72)',
|
||||
class:'fade',style:'animation-delay:1.1s'},'HALF RESOLVED BY HERE');
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#ACAD79','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
|
||||
'ONE RUNG = ONE TICKET IN A HUNDRED · DEEPER GREEN = TALLER PILE · DOT MARKS EVERY FIFTH');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ F15 · tick box ════
|
||||
// 箱线图的 Basics 皮:发丝须线 + 胶囊箱体(IQR),纸色横档 = 中位数,
|
||||
// 空心点 = 离群值。箱体明度按中位数快慢排:越快越黑。
|
||||
(()=>{
|
||||
const G=[['ENT',[.4,.9,1.5,2.6,4.4],[6.2]],
|
||||
['PRO',[.8,2.1,3.3,5,7.8],[10.5]],
|
||||
['STARTER',[1.5,3.8,6.1,8.9,13.2],[16.8]],
|
||||
['FREE',[2.2,6,9.4,13.8,19.6],[22.1,23.5]]];
|
||||
const SHADE=[INK,'#77835A','#929960','#ACAD79'];
|
||||
obsReveal('boxplot',s=>{
|
||||
const TOP=42,BASE=260,MAXH=24,mapY=v=>BASE-v/MAXH*(BASE-TOP);
|
||||
const colX=g=>78+g*84,BW=24;
|
||||
[0,6,12,18,24].forEach(h=>{
|
||||
el(s,'line',{x1:36,y1:mapY(h),x2:378,y2:mapY(h),stroke:'rgba(88,64,46,.12)','stroke-width':1.44,
|
||||
class:'fade',style:`animation-delay:${h*.008}s`});
|
||||
txt(s,{x:30,y:mapY(h)+3,'font-size':7.5,'font-weight':600,fill:MUTED,'text-anchor':'end',
|
||||
class:'fade',style:`animation-delay:${h*.008}s`},h+'h');
|
||||
});
|
||||
G.forEach(([name,[mn,q1,md,q3,mx],outs],g)=>{
|
||||
const x=colX(g);
|
||||
el(s,'line',{x1:x,y1:mapY(mn),x2:x,y2:mapY(mx),stroke:SHADE[g],'stroke-width':1.44,
|
||||
pathLength:1,class:'draw',style:`animation-delay:${g*.12}s;animation-duration:.6s`});
|
||||
[mn,mx].forEach(vv=>el(s,'line',{x1:x-7,y1:mapY(vv),x2:x+7,y2:mapY(vv),
|
||||
stroke:SHADE[g],'stroke-width':1.8,class:'fade',style:`animation-delay:${.2+g*.12}s`}));
|
||||
const box=el(s,'rect',{x:x-BW/2,y:mapY(q3),width:BW,height:mapY(q1)-mapY(q3),rx:9,
|
||||
fill:SHADE[g],class:'pop',style:`animation-delay:${.15+g*.12}s`});
|
||||
tip(box,`${name} — half of tickets answered in ${q1}–${q3}h`);
|
||||
el(s,'line',{x1:x-BW/2+3,y1:mapY(md),x2:x+BW/2-3,y2:mapY(md),stroke:PAPER,'stroke-width':2.2,
|
||||
class:'fade',style:`animation-delay:${.5+g*.12}s`});
|
||||
txt(s,{x:x+BW/2+6,y:mapY(md)+3,'font-size':9.5,'font-weight':800,fill:INK,
|
||||
class:'fade',style:`animation-delay:${.6+g*.12}s`},md.toFixed(1)+'h');
|
||||
outs.forEach((o,k)=>{
|
||||
const d=el(s,'circle',{cx:x+(rnd(k+1,g+3)-.5)*8,cy:mapY(o),r:2.6,fill:PAPER,
|
||||
stroke:SHADE[g],'stroke-width':1.3,class:'pop',style:`animation-delay:${.7+g*.12+k*.06}s`});
|
||||
tip(d,`${name} outlier — ${o}h`);
|
||||
});
|
||||
txt(s,{x,y:BASE+18,'font-size':7.5,'font-weight':700,fill:MUTED,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${g*.12}s`},name);
|
||||
});
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#ACAD79','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
|
||||
'BOX = MIDDLE HALF · PAPER TICK = MEDIAN · HOLLOW = OUTLIER · DARKEST = FASTEST');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ F16 · stream ribbon ════
|
||||
// Streamgraph / ThemeRiver:多系列构成随时间此消彼长。轮廓对称于中轴
|
||||
// (silhouette 基线),带宽 = 该系列当周量,纸色缝分带(treemap 同法)。
|
||||
// 三带明度按当前份额排:现任霸主最黑。带名标在各自最宽处。
|
||||
(()=>{
|
||||
const N=48;
|
||||
const mk=(base,trend,w1,w2,seed)=>Array.from({length:N},(_,t)=>
|
||||
Math.max(2,base+trend*t+10*Math.sin(t/w1+seed)+5*Math.sin(t/w2+seed*2)+rnd(t+1,seed)*6));
|
||||
// 三条带走绿→黄梯:现任霸主最深绿,退场的 LEGACY 落在麦黄(不再用灰褐透明档)
|
||||
const SERIES=[
|
||||
['LEGACY EDITOR',mk(46,-.62,9,3.7,2),'#F2D17E'],
|
||||
['BOARDS',mk(26,.10,11,4.2,5),'#929960'],
|
||||
['FLOWS',mk(12,.78,10,3.1,8),INK],
|
||||
];
|
||||
obsReveal('stream',s=>{
|
||||
const X0=36,X1=744,CY=138,SC=1.15;
|
||||
const x=t=>X0+t/(N-1)*(X1-X0);
|
||||
const tot=Array.from({length:N},(_,t)=>SERIES.reduce((a,S)=>a+S[1][t],0));
|
||||
// stacked offsets, silhouette baseline: top of stack starts at -total/2
|
||||
const y0=Array.from({length:N},(_,t)=>CY-tot[t]*SC/2);
|
||||
const smooth=pts=>{
|
||||
let d=`M${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;
|
||||
for(let k=1;k<pts.length-1;k++){
|
||||
const p=pts[k],q=pts[k+1];
|
||||
d+=` Q${p[0].toFixed(1)} ${p[1].toFixed(1)} ${((p[0]+q[0])/2).toFixed(1)} ${((p[1]+q[1])/2).toFixed(1)}`;
|
||||
}
|
||||
return d+` L${pts[pts.length-1][0].toFixed(1)} ${pts[pts.length-1][1].toFixed(1)}`;
|
||||
};
|
||||
let run=y0.slice();
|
||||
SERIES.forEach(([name,vals,shade],si)=>{
|
||||
const top=run.map((v,t)=>[x(t),v]);
|
||||
const bot=run.map((v,t)=>[x(t),v+vals[t]*SC]);
|
||||
run=bot.map(p=>p[1]);
|
||||
const d=smooth(top)+' L '+[...bot].reverse().map(p=>`${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' L ')+' Z';
|
||||
const band=el(s,'path',{d,fill:shade,stroke:PAPER,'stroke-width':2,
|
||||
class:'fade',style:`animation-delay:${si*.16}s`});
|
||||
tip(band,`${name} — weekly active accounts, 48 weeks`);
|
||||
// label at the band's widest week — clamped off both ends so the
|
||||
// paper-colored text never spills past the band onto paper (invisible)
|
||||
let wk=4;vals.forEach((v,t)=>{if(t>=4&&t<=N-6&&v>vals[wk])wk=t});
|
||||
const midY=(top[wk][1]+bot[wk][1])/2;
|
||||
const dark=shade===INK;
|
||||
txt(s,{x:x(wk),y:midY+3,'font-size':9,'font-weight':800,'text-anchor':'middle',
|
||||
fill:dark?PAPER:'#58402E','letter-spacing':'.06em',
|
||||
class:'fade',style:`animation-delay:${.5+si*.14}s`},name);
|
||||
});
|
||||
// month floor: barcode ticks + labels every 8 weeks
|
||||
const base=252;
|
||||
el(s,'line',{x1:X0-6,y1:base,x2:X1+6,y2:base,stroke:GRID,'stroke-width':1.44,class:'fade'});
|
||||
for(let t=0;t<N;t++)
|
||||
el(s,'line',{x1:x(t),y1:base,x2:x(t),y2:base-(t%8===0?7:4),stroke:'rgba(88,64,46,.24)','stroke-width':1.08,
|
||||
class:'fade',style:`animation-delay:${t*.006}s`});
|
||||
['JAN','MAR','MAY','JUL','SEP','NOV'].forEach((m,k)=>
|
||||
txt(s,{x:x(k*8),y:base+15,'font-size':7.5,'font-weight':600,fill:MUTED,'text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade'},m));
|
||||
txt(s,{x:390,y:288,'font-size':7,'font-weight':600,fill:'#ACAD79','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
|
||||
'BAND WIDTH = WEEKLY ACTIVES · DARKEST = TODAY’S LEADER · THE RIVER IS THE TOTAL');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ F17 · candlestick ════
|
||||
// K 线的 Mono 化:不用红绿——实心墨 = 收跌,空心(纸底墨框)= 收涨,
|
||||
// 和哑铃图 hollow/ink 的语义一脉。影线是发丝,柱体胶囊圆角。
|
||||
// OHLC 四值都是真实记录,最高/最低两日标数。30 个交易日。
|
||||
(()=>{
|
||||
const N=30;
|
||||
// deterministic random walk with a dip-and-recover arc
|
||||
const DAYS=[];
|
||||
let px=52;
|
||||
for(let d=0;d<N;d++){
|
||||
const drift=d<10?.4:d<18?-1.5:1.3;
|
||||
const open=px;
|
||||
const close=Math.max(30,open+drift+(rnd(d+1,3)-.5)*4.6);
|
||||
const hi=Math.max(open,close)+rnd(d+2,7)*2.6;
|
||||
const lo=Math.min(open,close)-rnd(d+3,11)*2.6;
|
||||
DAYS.push([open,close,hi,lo]);
|
||||
px=close;
|
||||
}
|
||||
obsReveal('candle',s=>{
|
||||
const X0=40,PW=11.2,BASE=258,TOP=44;
|
||||
const vmin=Math.min(...DAYS.map(d=>d[3])),vmax=Math.max(...DAYS.map(d=>d[2]));
|
||||
const mapY=v=>BASE-(v-vmin)/(vmax-vmin)*(BASE-TOP);
|
||||
const x=d=>X0+d*PW+PW/2;
|
||||
// price grid
|
||||
for(let g=0;g<=4;g++){
|
||||
const v=vmin+g/4*(vmax-vmin);
|
||||
el(s,'line',{x1:X0-4,y1:mapY(v),x2:X0+N*PW+4,y2:mapY(v),stroke:'rgba(88,64,46,.12)','stroke-width':1.44,
|
||||
class:'fade',style:`animation-delay:${g*.03}s`});
|
||||
txt(s,{x:X0-8,y:mapY(v)+3,'font-size':7.5,'font-weight':600,fill:MUTED,'text-anchor':'end',
|
||||
class:'fade',style:`animation-delay:${g*.03}s`},'$'+Math.round(v));
|
||||
}
|
||||
let hiD=0,loD=0;
|
||||
DAYS.forEach(([o,c,h,l],d)=>{
|
||||
if(h>DAYS[hiD][2])hiD=d;
|
||||
if(l<DAYS[loD][3])loD=d;
|
||||
});
|
||||
DAYS.forEach(([o,c,h,l],d)=>{
|
||||
const cx=x(d),up=c>=o;
|
||||
const yT=mapY(Math.max(o,c)),yB=mapY(Math.min(o,c));
|
||||
// wick: one hairline, full reach
|
||||
el(s,'line',{x1:cx,y1:mapY(h),x2:cx,y2:mapY(l),stroke:'rgba(88,64,46,.72)','stroke-width':1.26,
|
||||
class:'fade',style:`animation-delay:${d*.03}s`});
|
||||
// body: capsule; 涨跌是两个类目——深绿 = 收跌,麦黄 = 收涨(palm 的双分类位)
|
||||
const body=el(s,'rect',{x:cx-3.4,y:yT,width:6.8,height:Math.max(2.5,yB-yT),rx:3,
|
||||
...(up?{fill:'#F2D17E',stroke:'#77835A','stroke-width':1}:{fill:INK}),
|
||||
class:'fade',style:`animation-delay:${.05+d*.03}s`});
|
||||
tip(body,`Day ${d+1} — open $${o.toFixed(1)} · close $${c.toFixed(1)} · high $${h.toFixed(1)} · low $${l.toFixed(1)}`);
|
||||
// extremes, labeled with hairline flags
|
||||
if(d===hiD)txt(s,{x:cx,y:mapY(h)-7,'font-size':8,'font-weight':800,fill:INK,'text-anchor':'middle',
|
||||
style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:1s`,
|
||||
class:'fade'},'$'+Math.round(h));
|
||||
if(d===loD)txt(s,{x:cx,y:mapY(l)+14,'font-size':8,'font-weight':800,fill:'rgba(88,64,46,.72)','text-anchor':'middle',
|
||||
style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:1s`,
|
||||
class:'fade'},'$'+Math.round(l));
|
||||
});
|
||||
// week ticks on the floor
|
||||
el(s,'line',{x1:X0-4,y1:BASE+8,x2:X0+N*PW+4,y2:BASE+8,stroke:GRID,'stroke-width':1.44,class:'fade'});
|
||||
for(let d=0;d<N;d++)
|
||||
el(s,'line',{x1:x(d),y1:BASE+8,x2:x(d),y2:BASE+8-(d%5===0?6:3),stroke:'rgba(88,64,46,.24)','stroke-width':1.08,
|
||||
class:'fade',style:`animation-delay:${d*.008}s`});
|
||||
['W1','W2','W3','W4','W5','W6'].forEach((w,k)=>
|
||||
txt(s,{x:x(k*5),y:BASE+21,'font-size':7,'font-weight':600,fill:MUTED,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade'},w));
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#ACAD79','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
|
||||
'GREEN BODY = CLOSED DOWN · WHEAT = CLOSED UP · WICK = THE DAY’S FULL REACH');
|
||||
});
|
||||
})();
|
||||
})();
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,893 @@
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Lieflat Basics F1–F17 × PORCELAIN</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@6/dist/echarts.min.js"></script>
|
||||
<style>
|
||||
:root{--bg:#F7F2EB;--ink:#081F5C;--muted:rgba(8,31,92,.60);--faint:rgba(8,31,92,.32);--grid:rgba(8,31,92,.16)}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);font-family:'Inter','Noto Sans SC',sans-serif;color:var(--ink);padding:40px;-webkit-font-smoothing:antialiased}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:22px;max-width:1100px;margin:0 auto}
|
||||
.card{background:var(--bg);border-radius:24px;padding:28px 28px 20px}
|
||||
.card.wide{grid-column:1/-1}
|
||||
h2{font-weight:700;font-size:16.5px;letter-spacing:-.02em;margin-bottom:3px}
|
||||
.sub{font-size:11.5px;color:var(--muted);margin-bottom:14px}
|
||||
.src{font-size:9.5px;color:var(--faint);margin-top:10px;letter-spacing:.08em;font-weight:500}
|
||||
.badge{display:inline-block;font-size:9px;font-weight:700;letter-spacing:.08em;
|
||||
padding:3px 10px;border-radius:99px;margin-bottom:10px;border:1px dashed var(--faint);color:var(--muted)}
|
||||
svg{width:100%;max-height:330px;display:block;margin:0 auto}
|
||||
.chartbox{width:100%;height:360px}
|
||||
svg text{font-family:'Inter',sans-serif}
|
||||
.pop{transform-box:fill-box;transform-origin:center;animation:pop .5s cubic-bezier(.2,.7,.3,1.3) both}
|
||||
@keyframes pop{from{transform:scale(0)}to{transform:none}}
|
||||
.fade{animation:fade .9s ease both}
|
||||
@keyframes fade{from{opacity:0}}
|
||||
.draw{stroke-dasharray:1;stroke-dashoffset:1;animation:draw 1s cubic-bezier(.4,0,.2,1) both}
|
||||
@keyframes draw{to{stroke-dashoffset:0}}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.pop,.fade{animation:none}
|
||||
.draw{animation:none;stroke-dasharray:none;stroke-dashoffset:0}
|
||||
}
|
||||
.pagehead{max-width:1100px;margin:0 auto 26px;padding:0 4px}
|
||||
.pagehead h1{font-size:22px;letter-spacing:-.02em;font-weight:800}
|
||||
.pagehead p{font-size:12px;color:var(--muted);margin-top:5px;line-height:1.7}
|
||||
.chip{display:inline-block;width:12px;height:12px;border-radius:99px;margin:0 3px -1px 0;box-shadow:inset 0 0 0 1px rgba(0,0,0,.08)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="pagehead">
|
||||
<h1>基础型组 F1–F17 × PORCELAIN</h1>
|
||||
<p><span class="chip" style="background:#334EAC"></span><span class="chip" style="background:#7096D1"></span><span class="chip" style="background:#BAD6EB"></span><span class="chip" style="background:#D0E3FF"></span><span class="chip" style="background:#081F5C"></span><span class="chip" style="background:#F7F2EB"></span> 青瓷蓝家族 · Royal 当主墨,Midnight 当文字与最深档,九色号全原厂。几何、动画、数据与 basics-gallery 一致;彩色版统一加墨(线宽 ×1.8 / 透明度地板 .85,dot heat 除外——它的墨本来就够)。滚入播动画,点击重播。</p>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 柱状</span>
|
||||
<h2>Revenue by plan, rung by rung</h2>
|
||||
<div class="sub">one rung = one $k of MRR · the bar is a ladder you can count</div>
|
||||
<svg id="rungs" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">RUNG BARS · PORCELAIN · BILLING</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 折线</span>
|
||||
<h2>Thirty days of sign-ups</h2>
|
||||
<div class="sub">one dot = one day · hollow = weekend · the barcode floor keeps the calendar honest</div>
|
||||
<svg id="dayline" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">HAIRLINE LINE · PORCELAIN · GROWTH</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 面积</span>
|
||||
<h2>Concurrent users, filled with days</h2>
|
||||
<div class="sub">one hairline = one day, floor to peak · the area is made of days, not paint</div>
|
||||
<svg id="hairarea" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">HAIRLINE AREA · PORCELAIN · LIVE OPS</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 环形</span>
|
||||
<h2>Where the traffic comes from</h2>
|
||||
<div class="sub">one tick = one percent · four sources shade the dial · read it like a clock</div>
|
||||
<svg id="tickdonut" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">TICK DONUT · PORCELAIN · ACQUISITION</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 横向条形</span>
|
||||
<h2>Six teams, shipped and counted</h2>
|
||||
<div class="sub">one tick = one release this quarter · the row is a queue, not a bar</div>
|
||||
<svg id="tickrows" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">TICK ROWS · PORCELAIN · RELEASE LOG</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 分组柱</span>
|
||||
<h2>This year against last, plan by plan</h2>
|
||||
<div class="sub">two ladders per plan · solid rungs = 2026 · faint rungs = 2025 · one rung = $1k</div>
|
||||
<svg id="pairrungs" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">PAIRED RUNGS · PORCELAIN · BILLING YoY</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 堆叠柱</span>
|
||||
<h2>Where each region's revenue sits</h2>
|
||||
<div class="sub">one rung = $1k · three products stack up the ladder, strongest carries the most</div>
|
||||
<svg id="stackrungs" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">STACKED RUNGS · PORCELAIN · REVENUE MIX</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 散点</span>
|
||||
<h2>Price against satisfaction, twelve products</h2>
|
||||
<div class="sub">every dot hangs a plumb line to the floor · read x by the line, y by the height</div>
|
||||
<svg id="plumb" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">PLUMB SCATTER · PORCELAIN · PRODUCT PANEL</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 瀑布</span>
|
||||
<h2>From gross to net, step by step</h2>
|
||||
<div class="sub">one rung = $1k · solid rungs add, dashed rungs take away · the ladder walks down</div>
|
||||
<svg id="fall" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">RUNG WATERFALL · PORCELAIN · P&L</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 热力</span>
|
||||
<h2>When support gets loud</h2>
|
||||
<div class="sub">7 days × 12 hours · dot area = tickets · biggest cell labeled · silence stays visible</div>
|
||||
<svg id="dotheat" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">DOT HEAT · PORCELAIN · SUPPORT DESK</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 进度</span>
|
||||
<h2>How far to the quarter's goal</h2>
|
||||
<div class="sub">one tick = one percent of target · inked = earned · the dial reads left to right</div>
|
||||
<svg id="gauge" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">TICK GAUGE · PORCELAIN · QUARTERLY TARGET</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 哑铃对比</span>
|
||||
<h2>Onboarding, before and after the redesign</h2>
|
||||
<div class="sub">hollow dot = before · solid dot = after · beads on the track = minutes saved, countable</div>
|
||||
<svg id="dumbbell" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">DUMBBELL QUEUE · PORCELAIN · UX TIMING</div>
|
||||
</div>
|
||||
|
||||
<div class="card wide">
|
||||
<span class="badge">LUPI 基础型 · 矩形树图</span>
|
||||
<h2>Where the work went</h2>
|
||||
<div class="sub">1,280 shipped hours · area = effort · blue lightness = parent portfolio share</div>
|
||||
<div id="treemap" class="chartbox" role="img" aria-label="Porcelain treemap of shipped hours nested by product family"></div>
|
||||
<div class="src">NESTED TREEMAP · PORCELAIN · WORK PORTFOLIO</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- F14 · rung histogram -->
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 直方图</span>
|
||||
<h2>Most tickets resolve within six hours</h2>
|
||||
<div class="sub">one rung = one ticket in a hundred · bins of two hours · the ladder shows the pile</div>
|
||||
<svg id="histo" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">RUNG HISTOGRAM · PORCELAIN · SUPPORT DESK</div>
|
||||
</div>
|
||||
|
||||
<!-- F15 · tick box -->
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 箱线图</span>
|
||||
<h2>Reply times, boxed by plan</h2>
|
||||
<div class="sub">box = the middle half of tickets · paper tick = median · hollow dot = an outlier nobody enjoyed</div>
|
||||
<svg id="boxplot" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">TICK BOX · PORCELAIN · SUPPORT DESK</div>
|
||||
</div>
|
||||
|
||||
<!-- F16 · stream ribbon -->
|
||||
<div class="card wide">
|
||||
<span class="badge">LUPI 基础型 · STREAMGRAPH</span>
|
||||
<h2>Three products trade the same river</h2>
|
||||
<div class="sub">48 weeks of usage share · band width = weekly active accounts · the river swells where the total grows</div>
|
||||
<svg id="stream" viewBox="0 0 840 300" preserveAspectRatio="xMidYMid meet" style="max-height:300px"></svg>
|
||||
<div class="src">STREAM RIBBON · PORCELAIN · PRODUCT ANALYTICS</div>
|
||||
</div>
|
||||
|
||||
<!-- F17 · candlestick -->
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · K 线</span>
|
||||
<h2>Six weeks of the token, candle by candle</h2>
|
||||
<div class="sub">body = open to close · wick = the day's full reach · ink = closed down · hollow = closed up</div>
|
||||
<svg id="candle" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">CANDLESTICK · PORCELAIN · MARKET DATA</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
// ── PORCELAIN 角色 token(结构灰一律为文字色的透明档,不引入新色相)──
|
||||
const BG='#F7F2EB',TXT='#081F5C',MUT='rgba(8,31,92,.60)',LAB='rgba(8,31,92,.72)',FAINT='rgba(8,31,92,.32)';
|
||||
const FLOOR='rgba(8,31,92,.24)',QUIET='rgba(8,31,92,.15)',TRACK='rgba(8,31,92,.12)',GRID='rgba(8,31,92,.16)';
|
||||
const DATA='#334EAC',DATA2='#7096D1',HERO='#081F5C';
|
||||
const FAINTDATA='#BAD6EB',BEAD='#7096D1';
|
||||
const CAT4=["#081F5C", "#334EAC", "#7096D1", "#BAD6EB"],CAT3=["#334EAC", "#7096D1", "#BAD6EB"],CAT3L=["#334EAC", "#7096D1", "rgba(8,31,92,.6)"],HEAT=["#334EAC", "#7096D1", "#BAD6EB"];
|
||||
const HEROMODE=false; // 黑体红睛模式:每张图一个主角元素整体走 HERO
|
||||
const NS='http://www.w3.org/2000/svg';
|
||||
const el=(p,t,a)=>{const n=document.createElementNS(NS,t);for(const k in a)n.setAttribute(k,a[k]);p.appendChild(n);return n};
|
||||
const txt=(p,a,s)=>{const n=el(p,'text',a);n.textContent=s;return n};
|
||||
const tip=(n,s)=>{const t=document.createElementNS(NS,'title');t.textContent=s;n.appendChild(t)};
|
||||
const rnd=(i,k)=>Math.abs(((i*73856093)^(k*19349663))%1000)/1000;
|
||||
const D2R=Math.PI/180;
|
||||
const pol=(cx,cy,r,deg)=>[cx+r*Math.cos(deg*D2R),cy+r*Math.sin(deg*D2R)];
|
||||
const obsReveal=(id,fn)=>{
|
||||
const n=document.getElementById(id);
|
||||
const go=()=>{n.innerHTML='';fn(n)};
|
||||
const io=new IntersectionObserver(es=>{if(es[0].isIntersecting){go();io.disconnect()}},{threshold:.3});
|
||||
io.observe(n);
|
||||
n.style.cursor='pointer';
|
||||
n.addEventListener('click',go);
|
||||
};
|
||||
|
||||
// ════ B1 · rung bars ════
|
||||
(()=>{
|
||||
const D=[['FREE',38],['STARTER',27],['PRO',22],['TEAM',16],['SCALE',11],['ENT',7]];
|
||||
obsReveal('rungs',s=>{
|
||||
const x0=i=>56+i*56,base=266,step=5.6,HW=14;
|
||||
D.forEach(([name,v],i)=>{
|
||||
const x=x0(i);
|
||||
const colInk=(HEROMODE&&i===0)?HERO:DATA;
|
||||
for(let k=0;k<v;k++){
|
||||
const y=base-k*step,w=HW-1.5+rnd(k+1,i+2)*3;
|
||||
el(s,'line',{x1:x-w,y1:y,x2:x+w,y2:y,stroke:colInk,'stroke-width':1.8,
|
||||
opacity:.85+rnd(k+2,i+4)*.15,class:'fade',style:`animation-delay:${i*.08+k*.012}s`});
|
||||
if(k%5===4)el(s,'circle',{cx:x+HW+4.5,cy:y,r:.8,fill:FAINT,
|
||||
class:'fade',style:`animation-delay:${i*.08+k*.012}s`});
|
||||
}
|
||||
const topY=base-(v-1)*step;
|
||||
const num=txt(s,{x,y:topY-10,'font-size':11,'font-weight':800,fill:TXT,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${.4+i*.08}s`},v);
|
||||
tip(num,`${name} — $${v}k MRR`);
|
||||
txt(s,{x,y:base+18,'font-size':7.5,'font-weight':700,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.08}s`},name);
|
||||
});
|
||||
el(s,'line',{x1:28,y1:base+4,x2:372,y2:base+4,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:.9s'},
|
||||
'ONE RUNG = $1K · DOT MARKS EVERY FIFTH');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ B2 · hairline line ════
|
||||
(()=>{
|
||||
const N=30;
|
||||
const val=d=>46+22*Math.sin(d/4.6)+14*Math.sin(d/2.1)+rnd(d+1,5)*12;
|
||||
obsReveal('dayline',s=>{
|
||||
const x=d=>30+d*11.7,base=262,map=v=>base-v*2.15;
|
||||
for(let d=0;d<N;d++)
|
||||
el(s,'line',{x1:x(d),y1:base,x2:x(d),y2:base-7,stroke:FLOOR,'stroke-width':.6,
|
||||
class:'fade',style:`animation-delay:${d*.008}s`});
|
||||
el(s,'line',{x1:24,y1:base,x2:376,y2:base,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
const vs=Array.from({length:N},(_,d)=>val(d));
|
||||
const top=[];
|
||||
for(const d of [...vs.keys()].sort((a,b)=>vs[b]-vs[a])){
|
||||
if(top.every(t=>Math.abs(t-d)>=5))top.push(d);
|
||||
if(top.length===2)break;
|
||||
}
|
||||
const pts=vs.map((v,d)=>`${x(d)} ${map(v)}`).join(' L ');
|
||||
el(s,'path',{d:'M'+pts,fill:'none',stroke:DATA,'stroke-width':2.2,pathLength:1,
|
||||
class:'draw',style:'animation-duration:1.2s'});
|
||||
vs.forEach((v,d)=>{
|
||||
const weekend=d%7===5||d%7===6,big=top.includes(d);
|
||||
const dot=el(s,'circle',{cx:x(d),cy:map(v),r:big?5.2:3.1,
|
||||
fill:weekend?BG:(big?HERO:DATA),stroke:big?HERO:DATA,'stroke-width':weekend?1.4:0,
|
||||
class:'pop',style:`animation-delay:${.2+d*.03}s`});
|
||||
tip(dot,`Day ${d+1} — ${Math.round(v)} sign-ups`);
|
||||
if(big)txt(s,{x:x(d),y:map(v)-11,'font-size':9.5,'font-weight':800,fill:TXT,'text-anchor':'middle',
|
||||
style:`paint-order:stroke;stroke:${BG};stroke-width:3px;animation-delay:${1+d*.01}s`,
|
||||
class:'fade'},Math.round(v));
|
||||
});
|
||||
[[0,'JUN 1'],[14,'JUN 15'],[29,'JUN 30']].forEach(([d,m])=>
|
||||
txt(s,{x:x(d),y:base+18,'font-size':7.5,'font-weight':600,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade'},m));
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
|
||||
'ONE DOT = ONE DAY · HOLLOW = WEEKEND');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ B3 · hairline area ════
|
||||
(()=>{
|
||||
const N=45;
|
||||
const val=d=>34+26*Math.sin(d/7.2)+12*Math.sin(d/2.8)+rnd(d+1,3)*16;
|
||||
obsReveal('hairarea',s=>{
|
||||
const x=d=>28+d*7.75,base=262,map=v=>base-v*2.35;
|
||||
el(s,'line',{x1:22,y1:base,x2:378,y2:base,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
const vs=Array.from({length:N},(_,d)=>val(d));
|
||||
const peak=vs.indexOf(Math.max(...vs));
|
||||
vs.forEach((v,d)=>{
|
||||
el(s,'line',{x1:x(d),y1:base,x2:x(d),y2:map(v),
|
||||
stroke:d===peak?HERO:DATA,'stroke-width':d===peak?1.6:1,
|
||||
opacity:d===peak?1:.8+rnd(d+1,7)*.2,
|
||||
class:'fade',style:`animation-delay:${d*.014}s`});
|
||||
});
|
||||
const pts=vs.map((v,d)=>`${x(d)} ${map(v)}`).join(' L ');
|
||||
el(s,'path',{d:'M'+pts,fill:'none',stroke:DATA,'stroke-width':2,pathLength:1,
|
||||
class:'draw',style:'animation-delay:.4s;animation-duration:1.2s'});
|
||||
const pd=el(s,'circle',{cx:x(peak),cy:map(vs[peak]),r:4.2,fill:HERO,
|
||||
class:'pop',style:'animation-delay:1.2s'});
|
||||
tip(pd,`Day ${peak+1} — ${Math.round(vs[peak])}k peak`);
|
||||
txt(s,{x:x(peak),y:map(vs[peak])-11,'font-size':9.5,'font-weight':800,fill:TXT,'text-anchor':'middle',
|
||||
style:`paint-order:stroke;stroke:${BG};stroke-width:3px;animation-delay:1.3s`,
|
||||
class:'fade'},Math.round(vs[peak])+'k');
|
||||
[[0,'MAY'],[22,'JUN'],[44,'JUL']].forEach(([d,m])=>
|
||||
txt(s,{x:x(d),y:base+18,'font-size':7.5,'font-weight':600,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade'},m));
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.3s'},
|
||||
'ONE HAIRLINE = ONE DAY, FLOOR TO PEAK');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ B4 · tick donut ════
|
||||
(()=>{
|
||||
const D=[['ORGANIC',37,CAT4[0]],['PAID',28,CAT4[1]],['REFERRAL',21,CAT4[2]],['SOCIAL',14,CAT4[3]]];
|
||||
obsReveal('tickdonut',s=>{
|
||||
const cx=200,cy=148,R0=64;
|
||||
let k0=0;
|
||||
D.forEach(([name,v,shade],si)=>{
|
||||
for(let k=0;k<v;k++){
|
||||
const idx=k0+k,a=idx*3.6-90;
|
||||
const len=12+rnd(idx+1,si+2)*6;
|
||||
const [x1,y1]=pol(cx,cy,R0,a),[x2,y2]=pol(cx,cy,R0+len,a);
|
||||
el(s,'line',{x1,y1,x2,y2,stroke:shade,'stroke-width':1.9,
|
||||
class:'fade',style:`animation-delay:${idx*.012}s`});
|
||||
if(idx%10===0){
|
||||
const [dx,dy]=pol(cx,cy,R0-5,a);
|
||||
el(s,'circle',{cx:dx,cy:dy,r:.8,fill:FAINT,
|
||||
class:'fade',style:`animation-delay:${idx*.012}s`});
|
||||
}
|
||||
}
|
||||
const mid=(k0+v/2)*3.6-90,[lx,ly]=pol(cx,cy,R0+38,mid),[gx,gy]=pol(cx,cy,R0+20,mid);
|
||||
el(s,'line',{x1:gx,y1:gy,x2:lx,y2:ly,stroke:FAINT,'stroke-width':.7,
|
||||
'stroke-dasharray':'1 3',class:'fade',style:`animation-delay:${.6+si*.1}s`});
|
||||
const anchor=Math.cos(mid*D2R)>0.3?'start':Math.cos(mid*D2R)<-0.3?'end':'middle';
|
||||
const lab=txt(s,{x:lx,y:ly+3,'font-size':8,'font-weight':800,fill:shade,'text-anchor':anchor,
|
||||
'letter-spacing':'.06em',style:`paint-order:stroke;stroke:${BG};stroke-width:3px;animation-delay:${.65+si*.1}s`,
|
||||
class:'fade'},`${name} · ${v}`);
|
||||
tip(lab,`${name} — ${v}% of traffic`);
|
||||
k0+=v;
|
||||
});
|
||||
txt(s,{x:cx,y:cy-2,'font-size':22,'font-weight':800,fill:TXT,'text-anchor':'middle',
|
||||
class:'fade',style:'animation-delay:.9s'},'100');
|
||||
txt(s,{x:cx,y:cy+14,'font-size':7,'font-weight':600,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade',style:'animation-delay:.9s'},'TICKS · ONE = 1%');
|
||||
txt(s,{x:200,y:296,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
|
||||
'TWELVE O’CLOCK IS ZERO · DOT MARKS EVERY TENTH · READS CLOCKWISE');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C1 · tick rows ════
|
||||
(()=>{
|
||||
const D=[['PLATFORM',34],['GROWTH',28],['MOBILE',22],['INFRA',17],['ML',11],['DESIGN',8]];
|
||||
obsReveal('tickrows',s=>{
|
||||
const y0=i=>52+i*44,X0=104,PX=6.9;
|
||||
D.forEach(([name,v],i)=>{
|
||||
const y=y0(i);
|
||||
txt(s,{x:94,y:y+3,'font-size':8,'font-weight':700,fill:LAB,'text-anchor':'end',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.08}s`},name);
|
||||
el(s,'line',{x1:X0,y1:y+9,x2:X0+34*PX,y2:y+9,stroke:GRID,'stroke-width':.6,
|
||||
class:'fade',style:`animation-delay:${i*.08}s`});
|
||||
const rowInk=(HEROMODE&&i===0)?HERO:DATA;
|
||||
for(let k=0;k<v;k++){
|
||||
const x=X0+k*PX+PX/2,h=9+rnd(k+1,i+2)*6;
|
||||
el(s,'line',{x1:x,y1:y+9,x2:x,y2:y+9-h,stroke:rowInk,'stroke-width':1.7,
|
||||
opacity:.85+rnd(k+3,i+5)*.15,class:'fade',style:`animation-delay:${i*.08+k*.012}s`});
|
||||
if(k%5===4)el(s,'circle',{cx:x,cy:y+13,r:.8,fill:FAINT,
|
||||
class:'fade',style:`animation-delay:${i*.08+k*.012}s`});
|
||||
}
|
||||
const lab=txt(s,{x:X0+v*PX+10,y:y+4,'font-size':11,'font-weight':800,fill:TXT,
|
||||
class:'fade',style:`animation-delay:${.4+i*.08}s`},v);
|
||||
tip(lab,`${name} — ${v} releases`);
|
||||
});
|
||||
txt(s,{x:200,y:308,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:.9s'},
|
||||
'ONE TICK = ONE RELEASE · DOT MARKS EVERY FIFTH');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C2 · paired rungs ════
|
||||
(()=>{
|
||||
const D=[['FREE',31,38],['STARTER',22,27],['PRO',16,22],['TEAM',13,16],['ENT',6,9]];
|
||||
obsReveal('pairrungs',s=>{
|
||||
const x0=i=>64+i*66,base=258,step=5.4,HW=10;
|
||||
D.forEach(([name,was,now],i)=>{
|
||||
const xa=x0(i)-13,xb=x0(i)+13;
|
||||
for(let k=0;k<was;k++){
|
||||
const y=base-k*step,w=HW-1.2+rnd(k+1,i+2)*2.4;
|
||||
el(s,'line',{x1:xa-w,y1:y,x2:xa+w,y2:y,stroke:FAINTDATA,'stroke-width':1.8,
|
||||
opacity:.75+rnd(k+2,i+3)*.25,class:'fade',style:`animation-delay:${i*.08+k*.01}s`});
|
||||
}
|
||||
for(let k=0;k<now;k++){
|
||||
const y=base-k*step,w=HW-1.2+rnd(k+1,i+7)*2.4;
|
||||
el(s,'line',{x1:xb-w,y1:y,x2:xb+w,y2:y,stroke:DATA,'stroke-width':1.8,
|
||||
opacity:.85+rnd(k+2,i+8)*.15,class:'fade',style:`animation-delay:${.15+i*.08+k*.01}s`});
|
||||
}
|
||||
const topB=base-(now-1)*step;
|
||||
const num=txt(s,{x:xb,y:topB-9,'font-size':10.5,'font-weight':800,fill:HEROMODE?HERO:TXT,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${.5+i*.08}s`},now);
|
||||
tip(num,`${name} — $${was}k → $${now}k`);
|
||||
txt(s,{x:xa,y:base-(was-1)*step-9,'font-size':8.5,'font-weight':700,fill:FAINTDATA,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${.5+i*.08}s`},was);
|
||||
txt(s,{x:x0(i),y:base+18,'font-size':7.5,'font-weight':700,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.08}s`},name);
|
||||
});
|
||||
el(s,'line',{x1:30,y1:base+4,x2:370,y2:base+4,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1s'},
|
||||
'FAINT = 2025 · SOLID = 2026 · ONE RUNG = $1K');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C3 · stacked rungs ════
|
||||
(()=>{
|
||||
const D=[['NA',[18,11,7]],['EU',[14,9,5]],['APAC',[9,7,6]],['LATAM',[5,4,2]]];
|
||||
const SEG=['CORE','ADD-ONS','SERVICES'];
|
||||
obsReveal('stackrungs',s=>{
|
||||
const x0=i=>72+i*76,base=262,step=5.2,HW=13;
|
||||
D.forEach(([name,segs],i)=>{
|
||||
const x=x0(i);let k0=0;
|
||||
segs.forEach((v,si)=>{
|
||||
for(let k=0;k<v;k++){
|
||||
const y=base-(k0+k+si)*step,w=HW-1.4+rnd(k+1,i*3+si+2)*2.8;
|
||||
el(s,'line',{x1:x-w,y1:y,x2:x+w,y2:y,stroke:CAT3[si],'stroke-width':1.8,
|
||||
opacity:.85+rnd(k+2,i+si+4)*.15,class:'fade',
|
||||
style:`animation-delay:${i*.09+(k0+k)*.012}s`});
|
||||
}
|
||||
const midY=base-(k0+v/2+si)*step;
|
||||
const lab=txt(s,{x:x+HW+7,y:midY+2.5,'font-size':8,'font-weight':800,fill:CAT3L[si],
|
||||
class:'fade',style:`animation-delay:${.5+i*.09+si*.06}s`},v);
|
||||
tip(lab,`${name} ${SEG[si]} — $${v}k`);
|
||||
k0+=v;
|
||||
});
|
||||
const total=segs[0]+segs[1]+segs[2];
|
||||
txt(s,{x,y:base-(k0+2)*step-8,'font-size':10.5,'font-weight':800,fill:TXT,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${.6+i*.09}s`},total);
|
||||
txt(s,{x,y:base+18,'font-size':7.5,'font-weight':700,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.09}s`},name);
|
||||
});
|
||||
el(s,'line',{x1:36,y1:base+4,x2:364,y2:base+4,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
|
||||
'STRONGEST = CORE · MID = ADD-ONS · PALE = SERVICES · ONE RUNG = $1K');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C4 · plumb scatter ════
|
||||
(()=>{
|
||||
const P=[['Editor',72,86],['Boards',58,74],['Docs',44,79],['Chat',38,62],['Flows',66,58],
|
||||
['Vault',82,71],['Pages',28,55],['Sync',52,49],['Grid',88,44],['Views',20,68],
|
||||
['Hub',76,32],['Forms',34,38]];
|
||||
obsReveal('plumb',s=>{
|
||||
const X0=48,X1=368,base=258,mapX=p=>X0+p/100*(X1-X0),mapY=v=>base-(v-20)*2.6;
|
||||
for(let g=0;g<=20;g++){
|
||||
const x=X0+g/20*(X1-X0);
|
||||
el(s,'line',{x1:x,y1:base,x2:x,y2:base-(g%5===0?7:4),stroke:FLOOR,'stroke-width':.6,
|
||||
class:'fade',style:`animation-delay:${g*.01}s`});
|
||||
}
|
||||
el(s,'line',{x1:X0-6,y1:base,x2:X1+6,y2:base,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
txt(s,{x:X0,y:base+16,'font-size':7,'font-weight':600,fill:FAINT,class:'fade'},'CHEAP');
|
||||
txt(s,{x:X1,y:base+16,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'end',
|
||||
class:'fade'},'PREMIUM');
|
||||
const best=P.reduce((a,b)=>b[2]>a[2]?b:a),worst=P.reduce((a,b)=>b[2]<a[2]?b:a);
|
||||
P.forEach(([name,px,sat],i)=>{
|
||||
const x=mapX(px),y=mapY(sat),hero=name===best[0]||name===worst[0];
|
||||
el(s,'line',{x1:x,y1:base,x2:x,y2:y,stroke:FAINTDATA,'stroke-width':1,
|
||||
opacity:.75,class:'fade',style:`animation-delay:${.2+i*.05}s`});
|
||||
const dot=el(s,'circle',{cx:x,cy:y,r:hero?5.4:3.4,fill:hero?HERO:DATA,
|
||||
class:'pop',style:`animation-delay:${.25+i*.05}s`});
|
||||
tip(dot,`${name} — price ${px} · satisfaction ${sat}`);
|
||||
if(hero)txt(s,{x,y:y-10,'font-size':8.5,'font-weight':800,fill:TXT,'text-anchor':'middle',
|
||||
style:`paint-order:stroke;stroke:${BG};stroke-width:3px;animation-delay:${.8}s`,
|
||||
class:'fade'},`${name} · ${sat}`);
|
||||
});
|
||||
txt(s,{x:26,y:mapY(85),'font-size':7,'font-weight':600,fill:FAINT,
|
||||
transform:`rotate(-90 26 ${mapY(85)})`,'text-anchor':'end','letter-spacing':'.08em',class:'fade'},'HAPPIER ↑');
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1s'},
|
||||
'EVERY DOT HANGS A PLUMB LINE · READ X AT THE FLOOR');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C5 · rung waterfall ════
|
||||
(()=>{
|
||||
const D=[['GROSS',42,null],['REFUNDS',-6,null],['COGS',-11,null],['OPS',-8,null],['NET',17,'total']];
|
||||
obsReveal('fall',s=>{
|
||||
const x0=i=>58+i*72,step=5.2,HW=11;
|
||||
let lv=0;const rows=[];
|
||||
D.forEach(([name,v],i)=>{
|
||||
if(name==='GROSS'){rows.push([name,v,0,v]);lv=v}
|
||||
else if(name==='NET'){rows.push([name,lv,0,lv])}
|
||||
else{rows.push([name,v,lv+v,lv]);lv=lv+v}
|
||||
});
|
||||
const base=252,yOf=k=>base-k*step;
|
||||
rows.forEach(([name,v,lo,hi],i)=>{
|
||||
const x=x0(i),isTotal=name==='GROSS'||name==='NET',neg=v<0&&!isTotal;
|
||||
const from=isTotal?0:lo,to=isTotal?v:hi,n=Math.abs(to-from);
|
||||
const colInk=(HEROMODE&&name==='NET')?HERO:DATA;
|
||||
for(let k=0;k<n;k++){
|
||||
const y=yOf(from+k),w=HW-1.2+rnd(k+1,i+2)*2.4;
|
||||
if(neg)el(s,'line',{x1:x-w,y1:y,x2:x+w,y2:y,stroke:DATA2,'stroke-width':1.8,
|
||||
'stroke-dasharray':'2.5 2.5',opacity:.85,class:'fade',
|
||||
style:`animation-delay:${i*.12+k*.014}s`});
|
||||
else el(s,'line',{x1:x-w,y1:y,x2:x+w,y2:y,stroke:colInk,'stroke-width':1.8,
|
||||
opacity:.85+rnd(k+2,i+4)*.15,class:'fade',style:`animation-delay:${i*.12+k*.014}s`});
|
||||
}
|
||||
if(i<rows.length-1){
|
||||
const nx=x0(i+1);
|
||||
const lvl=isTotal?v:(neg?lo:hi);
|
||||
el(s,'line',{x1:x+HW+2,y1:yOf(lvl),x2:nx-HW-2,y2:yOf(lvl),stroke:FAINT,
|
||||
'stroke-width':.7,'stroke-dasharray':'2 3',class:'fade',
|
||||
style:`animation-delay:${.3+i*.12}s`});
|
||||
}
|
||||
const topY=yOf(Math.max(from,to));
|
||||
const num=txt(s,{x,y:topY-8,'font-size':10,'font-weight':800,
|
||||
fill:neg?DATA2:TXT,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${.4+i*.12}s`},(neg?'−':'')+Math.abs(v));
|
||||
tip(num,`${name} — ${neg?'−':''}$${Math.abs(v)}k`);
|
||||
txt(s,{x,y:base+18,'font-size':7.5,'font-weight':700,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.12}s`},name);
|
||||
});
|
||||
el(s,'line',{x1:30,y1:base+4,x2:370,y2:base+4,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
txt(s,{x:200,y:302,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
|
||||
'SOLID RUNGS ADD · DASHED RUNGS TAKE AWAY · ONE RUNG = $1K');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C6 · dot heat ════
|
||||
(()=>{
|
||||
const DAY=['MON','TUE','WED','THU','FRI','SAT','SUN'];
|
||||
obsReveal('dotheat',s=>{
|
||||
const x0=j=>64+j*27,y0=i=>58+i*30;
|
||||
let max=0,mi=0,mj=0;
|
||||
const v=(i,j)=>{
|
||||
const day=i<5?1:.32;
|
||||
const hour=Math.exp(-((j-4.6)**2)/7)+.7*Math.exp(-((j-8.4)**2)/5);
|
||||
const raw=day*hour*22*(0.6+rnd(i*12+j+1,j+3)*.8);
|
||||
return Math.round(raw);
|
||||
};
|
||||
for(let i=0;i<7;i++)for(let j=0;j<12;j++){const t=v(i,j);if(t>max){max=t;mi=i;mj=j}}
|
||||
for(let i=0;i<7;i++){
|
||||
txt(s,{x:50,y:y0(i)+3,'font-size':7.5,'font-weight':700,fill:LAB,'text-anchor':'end',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.05}s`},DAY[i]);
|
||||
for(let j=0;j<12;j++){
|
||||
const t=v(i,j),x=x0(j),y=y0(i);
|
||||
if(!t){el(s,'circle',{cx:x,cy:y,r:.8,fill:QUIET,
|
||||
class:'pop',style:`animation-delay:${i*.05+j*.015}s`});continue}
|
||||
const hero=i===mi&&j===mj;
|
||||
const dot=el(s,'circle',{cx:x,cy:y,r:1.2+Math.sqrt(t)*2.1,
|
||||
fill:t>max*.66?HEAT[0]:t>max*.33?HEAT[1]:HEAT[2],
|
||||
class:'pop',style:`animation-delay:${i*.05+j*.015}s`});
|
||||
tip(dot,`${DAY[i]} ${8+j}:00 — ${t} tickets`);
|
||||
if(hero)el(s,'circle',{cx:x,cy:y,r:1.2+Math.sqrt(t)*2.1+3.4,fill:'none',
|
||||
stroke:HERO,'stroke-width':1,'stroke-dasharray':'2 3',
|
||||
class:'fade',style:'animation-delay:1s'});
|
||||
}
|
||||
}
|
||||
for(let j=0;j<12;j+=2)
|
||||
txt(s,{x:x0(j),y:y0(6)+26,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${j*.02}s`},(8+j)+':00');
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
|
||||
`DOT AREA = TICKETS · DASHED RING = THE PEAK, ${max} · TINY DOT = A QUIET HOUR`);
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C7 · tick gauge ════
|
||||
(()=>{
|
||||
const GOAL=73;
|
||||
obsReveal('gauge',s=>{
|
||||
const cx=200,cy=190,R0=104,A0=-195,SW=210;
|
||||
for(let k=0;k<100;k++){
|
||||
const a=A0+k/100*SW,inked=k<GOAL;
|
||||
const len=inked?15+rnd(k+1,3)*6:5+rnd(k+1,7)*2.5;
|
||||
const [x1,y1]=pol(cx,cy,R0,a),[x2,y2]=pol(cx,cy,R0+len,a);
|
||||
el(s,'line',{x1,y1,x2,y2,stroke:inked?DATA:FLOOR,'stroke-width':inked?1.9:.6,
|
||||
class:'fade',style:`animation-delay:${k*.012}s`});
|
||||
}
|
||||
[25,50,75,100].forEach(m=>{
|
||||
const a=A0+m/100*SW,[dx,dy]=pol(cx,cy,R0-7,a),[tx2,ty2]=pol(cx,cy,R0-19,a);
|
||||
el(s,'circle',{cx:dx,cy:dy,r:1,fill:FAINT,class:'fade',style:'animation-delay:.8s'});
|
||||
txt(s,{x:tx2,y:ty2+3,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
class:'fade',style:'animation-delay:.85s'},m);
|
||||
});
|
||||
const aT=A0+GOAL/100*SW,[ex,ey]=pol(cx,cy,R0+20,aT);
|
||||
el(s,'circle',{cx:ex,cy:ey,r:3.2,fill:HERO,class:'pop',style:'animation-delay:1.1s'});
|
||||
const num=txt(s,{x:cx,y:cy-4,'font-size':34,'font-weight':800,fill:TXT,'text-anchor':'middle',
|
||||
class:'fade',style:'animation-delay:1s'},GOAL+'%');
|
||||
tip(num,`$730k of the $1M quarter target`);
|
||||
txt(s,{x:cx,y:cy+16,'font-size':8,'font-weight':600,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade',style:'animation-delay:1.05s'},'27 TICKS TO GO');
|
||||
txt(s,{x:200,y:300,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
|
||||
'ONE TICK = 1% OF TARGET · INKED = EARNED');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C8 · dumbbell queue ════
|
||||
(()=>{
|
||||
const D=[['INVITE FLOW',14,6],['FIRST BOARD',19,9],['IMPORT DATA',26,13],['TEAM SETUP',31,21],['GO LIVE',38,30]];
|
||||
obsReveal('dumbbell',s=>{
|
||||
const y0=i=>56+i*46,X0=118,X1=364,mapX=v=>X0+(v-4)/36*(X1-X0);
|
||||
D.forEach(([name,was,now],i)=>{
|
||||
const y=y0(i),xa=mapX(was),xb=mapX(now);
|
||||
txt(s,{x:108,y:y+3,'font-size':7.5,'font-weight':700,fill:LAB,'text-anchor':'end',
|
||||
'letter-spacing':'.06em',class:'fade',style:`animation-delay:${i*.08}s`},name);
|
||||
el(s,'line',{x1:X0-6,y1:y,x2:X1+6,y2:y,stroke:TRACK,'stroke-width':.7,
|
||||
class:'fade',style:`animation-delay:${i*.08}s`});
|
||||
const n=was-now;
|
||||
for(let k=0;k<n;k++){
|
||||
const t=(k+.5)/n,x=xb+t*(xa-xb),yy=y+(rnd(k+1,i+3)-.5)*2.6;
|
||||
el(s,'circle',{cx:x,cy:yy,r:2.1+rnd(k+2,i+4)*.9,fill:BEAD,opacity:.95,
|
||||
class:'pop',style:`animation-delay:${.3+i*.08+k*.03}s`});
|
||||
}
|
||||
el(s,'circle',{cx:xa,cy:y,r:4.8,fill:BG,stroke:DATA,'stroke-width':1.7,
|
||||
class:'pop',style:`animation-delay:${.2+i*.08}s`});
|
||||
const after=el(s,'circle',{cx:xb,cy:y,r:5.2,fill:HERO,
|
||||
class:'pop',style:`animation-delay:${.6+i*.08}s`});
|
||||
tip(after,`${name} — ${was} min → ${now} min`);
|
||||
txt(s,{x:xa+10,y:y-8,'font-size':8.5,'font-weight':700,fill:FAINTDATA,
|
||||
class:'fade',style:`animation-delay:${.3+i*.08}s`},was);
|
||||
txt(s,{x:xb-10,y:y-8,'font-size':10,'font-weight':800,fill:TXT,'text-anchor':'end',
|
||||
class:'fade',style:`animation-delay:${.7+i*.08}s`},now);
|
||||
});
|
||||
txt(s,{x:X0,y:290,'font-size':7,'font-weight':600,fill:FAINT,class:'fade'},'FASTER ←');
|
||||
txt(s,{x:X1,y:290,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'end',
|
||||
class:'fade'},'MINUTES');
|
||||
txt(s,{x:200,y:308,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1s'},
|
||||
'ONE BEAD = ONE MINUTE SAVED · HOLLOW = BEFORE · SOLID = AFTER');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C9 · nested treemap ════
|
||||
(()=>{
|
||||
const TOTAL=1280;
|
||||
const leaf=(name,value,color,labelColor=BG)=>({name,value,itemStyle:{color},label:{color:labelColor}});
|
||||
const TREE_DATA=[
|
||||
{name:'PRODUCT · 50%',children:[leaf('Core app',260,HERO),leaf('Collaboration',190,HERO),leaf('Search',110,HERO),leaf('Mobile',80,HERO)]},
|
||||
{name:'PLATFORM · 31%',children:[leaf('AI systems',170,DATA),leaf('Data infra',120,DATA),leaf('APIs',105,DATA)]},
|
||||
{name:'GROWTH · 19%',children:[leaf('Acquisition',90,FAINTDATA,TXT),leaf('Retention',80,FAINTDATA,TXT),leaf('Onboarding',75,FAINTDATA,TXT)]},
|
||||
];
|
||||
const host=document.getElementById('treemap');let chart;
|
||||
const draw=()=>{chart=chart||echarts.init(host,null,{renderer:'svg'});chart.clear();chart.setOption({
|
||||
animationDuration:900,animationEasing:'quarticOut',aria:{enabled:true},
|
||||
tooltip:{backgroundColor:BG,borderColor:TXT,borderWidth:1,textStyle:{color:TXT,fontSize:12},
|
||||
formatter:i=>`${i.treePathInfo.slice(1).map(d=>d.name.replace(/ · .*$/,'')).join(' / ')}<br><b>${i.value} hours</b> · ${(i.value/TOTAL*100).toFixed(1)}%`},
|
||||
series:[{type:'treemap',data:TREE_DATA,top:0,right:0,bottom:0,left:0,roam:false,nodeClick:false,breadcrumb:{show:false},leafDepth:2,squareRatio:1.2,
|
||||
label:{show:true,position:'insideTopLeft',padding:[10,8],color:TXT,fontSize:12,fontWeight:700,lineHeight:18,
|
||||
formatter:p=>p.treePathInfo.length<=1?'':p.treePathInfo.length===2?p.name:`${p.name}\n${p.value} h`},
|
||||
upperLabel:{show:true,height:32,padding:[0,10],color:TXT,fontSize:10,fontWeight:700,backgroundColor:BG},
|
||||
itemStyle:{borderColor:BG,borderWidth:2,gapWidth:2},emphasis:{focus:'ancestor',itemStyle:{borderColor:TXT,borderWidth:1.5}},
|
||||
levels:[{itemStyle:{borderWidth:0,gapWidth:5}},{upperLabel:{show:true,height:32},itemStyle:{borderColor:BG,borderWidth:3,gapWidth:3}},{upperLabel:{show:false},itemStyle:{borderColor:BG,borderWidth:2,gapWidth:2}}]}]});};
|
||||
const io=new IntersectionObserver(es=>{if(es[0].isIntersecting){draw();io.disconnect()}},{threshold:.25});
|
||||
io.observe(host);host.style.cursor='pointer';host.addEventListener('click',draw);addEventListener('resize',()=>chart?.resize());
|
||||
})();
|
||||
|
||||
// ── New chart-family helpers (scoped to the added templates) ──
|
||||
(()=>{
|
||||
const INK='#081F5C',PAPER='#F7F2EB',MUTED='rgba(8,31,92,.60)',GRID='rgba(8,31,92,.16)';
|
||||
const NS='http://www.w3.org/2000/svg';
|
||||
const el=(p,t,a)=>{const n=document.createElementNS(NS,t);for(const k in a)n.setAttribute(k,a[k]);p.appendChild(n);return n};
|
||||
const txt=(p,a,s)=>{const n=el(p,'text',a);n.textContent=s;return n};
|
||||
const tip=(n,s)=>{const t=document.createElementNS(NS,'title');t.textContent=s;n.appendChild(t)};
|
||||
const rnd=(i,k)=>Math.abs(((i*73856093)^(k*19349663))%1000)/1000;
|
||||
|
||||
// ════ F14 · rung histogram ════
|
||||
// 直方图的 Lupi Basics 皮:连续数值分箱,一箱一把梯子,1 档 = 1 张工单
|
||||
// (百分之一)。箱界写在梯脚之间——直方图的箱是区间不是类目,
|
||||
// 这是它和 F1 Rung Bars 的本质区别。峰值箱标数,中位数落点小旗标注。
|
||||
(()=>{
|
||||
const BIN=[6,14,22,19,13,9,6,4,3,2,1,1]; // 2h bins, sums to 100
|
||||
obsReveal('histo',s=>{
|
||||
const X0=44,PW=27.5,base=252,step=5.4,HW=10.5;
|
||||
BIN.forEach((v,i)=>{
|
||||
const x=X0+i*PW+PW/2;
|
||||
for(let k=0;k<v;k++){
|
||||
const y=base-k*step,w=HW-1.4+rnd(k+1,i+2)*2.8;
|
||||
el(s,'line',{x1:x-w,y1:y,x2:x+w,y2:y,stroke:INK,'stroke-width':1.8,
|
||||
opacity:.55+rnd(k+2,i+4)*.45,class:'fade',style:`animation-delay:${i*.06+k*.012}s`});
|
||||
if(k%5===4)el(s,'circle',{cx:x+HW+3.5,cy:y,r:.7,fill:'rgba(8,31,92,.32)',
|
||||
class:'fade',style:`animation-delay:${i*.06+k*.012}s`});
|
||||
}
|
||||
});
|
||||
// peak label
|
||||
const pk=BIN.indexOf(Math.max(...BIN));
|
||||
const px=X0+pk*PW+PW/2;
|
||||
const num=txt(s,{x:px,y:base-(BIN[pk]-1)*step-10,'font-size':11,'font-weight':800,fill:INK,
|
||||
'text-anchor':'middle',class:'fade',style:'animation-delay:.8s'},BIN[pk]);
|
||||
tip(num,`${BIN[pk]} of 100 tickets took ${pk*2}–${pk*2+2}h`);
|
||||
// bin edges: the continuous axis is the histogram's identity
|
||||
el(s,'line',{x1:X0-4,y1:base+4,x2:X0+12*PW+4,y2:base+4,stroke:GRID,'stroke-width':1.44,class:'fade'});
|
||||
for(let e=0;e<=12;e++){
|
||||
const x=X0+e*PW;
|
||||
el(s,'line',{x1:x,y1:base+4,x2:x,y2:base+(e%2===0?11:8),stroke:'rgba(8,31,92,.24)','stroke-width':1.08,
|
||||
class:'fade',style:`animation-delay:${e*.02}s`});
|
||||
if(e%2===0)txt(s,{x,y:base+21,'font-size':7,'font-weight':600,fill:MUTED,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${e*.02}s`},e*2+'h');
|
||||
}
|
||||
// median flag: cumulative crosses 50 inside bin 2
|
||||
let acc=0,mb=0;for(let i=0;i<12;i++){acc+=BIN[i];if(acc>=50){mb=i;break}}
|
||||
const mx=X0+mb*PW+PW*.7;
|
||||
el(s,'line',{x1:mx,y1:base+4,x2:mx,y2:base-BIN[mb]*step-16,stroke:'#7096D1','stroke-width':1.62,
|
||||
'stroke-dasharray':'2 4',class:'fade',style:'animation-delay:1s'});
|
||||
txt(s,{x:mx+4,y:base-BIN[mb]*step-20,'font-size':7.5,'font-weight':800,fill:'rgba(8,31,92,.72)',
|
||||
class:'fade',style:'animation-delay:1.1s'},'HALF RESOLVED BY HERE');
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#BAD6EB','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
|
||||
'ONE RUNG = ONE TICKET IN A HUNDRED · BINS OF TWO HOURS · DOT MARKS EVERY FIFTH');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ F15 · tick box ════
|
||||
// 箱线图的 Basics 皮:发丝须线 + 胶囊箱体(IQR),纸色横档 = 中位数,
|
||||
// 空心点 = 离群值。箱体明度按中位数快慢排:越快越黑。
|
||||
(()=>{
|
||||
const G=[['ENT',[.4,.9,1.5,2.6,4.4],[6.2]],
|
||||
['PRO',[.8,2.1,3.3,5,7.8],[10.5]],
|
||||
['STARTER',[1.5,3.8,6.1,8.9,13.2],[16.8]],
|
||||
['FREE',[2.2,6,9.4,13.8,19.6],[22.1,23.5]]];
|
||||
const SHADE=[INK,'#334EAC','#7096D1','#BAD6EB'];
|
||||
obsReveal('boxplot',s=>{
|
||||
const TOP=42,BASE=260,MAXH=24,mapY=v=>BASE-v/MAXH*(BASE-TOP);
|
||||
const colX=g=>78+g*84,BW=24;
|
||||
[0,6,12,18,24].forEach(h=>{
|
||||
el(s,'line',{x1:36,y1:mapY(h),x2:378,y2:mapY(h),stroke:'rgba(8,31,92,.12)','stroke-width':1.44,
|
||||
class:'fade',style:`animation-delay:${h*.008}s`});
|
||||
txt(s,{x:30,y:mapY(h)+3,'font-size':7.5,'font-weight':600,fill:MUTED,'text-anchor':'end',
|
||||
class:'fade',style:`animation-delay:${h*.008}s`},h+'h');
|
||||
});
|
||||
G.forEach(([name,[mn,q1,md,q3,mx],outs],g)=>{
|
||||
const x=colX(g);
|
||||
el(s,'line',{x1:x,y1:mapY(mn),x2:x,y2:mapY(mx),stroke:'#7096D1','stroke-width':1.44,
|
||||
pathLength:1,class:'draw',style:`animation-delay:${g*.12}s;animation-duration:.6s`});
|
||||
[mn,mx].forEach(vv=>el(s,'line',{x1:x-7,y1:mapY(vv),x2:x+7,y2:mapY(vv),
|
||||
stroke:'#7096D1','stroke-width':1.8,class:'fade',style:`animation-delay:${.2+g*.12}s`}));
|
||||
const box=el(s,'rect',{x:x-BW/2,y:mapY(q3),width:BW,height:mapY(q1)-mapY(q3),rx:9,
|
||||
fill:SHADE[g],class:'pop',style:`animation-delay:${.15+g*.12}s`});
|
||||
tip(box,`${name} — half of tickets answered in ${q1}–${q3}h`);
|
||||
el(s,'line',{x1:x-BW/2+3,y1:mapY(md),x2:x+BW/2-3,y2:mapY(md),stroke:PAPER,'stroke-width':2.2,
|
||||
class:'fade',style:`animation-delay:${.5+g*.12}s`});
|
||||
txt(s,{x:x+BW/2+6,y:mapY(md)+3,'font-size':9.5,'font-weight':800,fill:INK,
|
||||
class:'fade',style:`animation-delay:${.6+g*.12}s`},md.toFixed(1)+'h');
|
||||
outs.forEach((o,k)=>{
|
||||
const d=el(s,'circle',{cx:x+(rnd(k+1,g+3)-.5)*8,cy:mapY(o),r:2.6,fill:PAPER,
|
||||
stroke:'rgba(8,31,92,.72)','stroke-width':1.98,class:'pop',style:`animation-delay:${.7+g*.12+k*.06}s`});
|
||||
tip(d,`${name} outlier — ${o}h`);
|
||||
});
|
||||
txt(s,{x,y:BASE+18,'font-size':7.5,'font-weight':700,fill:MUTED,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${g*.12}s`},name);
|
||||
});
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#BAD6EB','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
|
||||
'BOX = MIDDLE HALF · PAPER TICK = MEDIAN · HOLLOW = OUTLIER · DARKEST = FASTEST');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ F16 · stream ribbon ════
|
||||
// Streamgraph / ThemeRiver:多系列构成随时间此消彼长。轮廓对称于中轴
|
||||
// (silhouette 基线),带宽 = 该系列当周量,纸色缝分带(treemap 同法)。
|
||||
// 三带明度按当前份额排:现任霸主最黑。带名标在各自最宽处。
|
||||
(()=>{
|
||||
const N=48;
|
||||
const mk=(base,trend,w1,w2,seed)=>Array.from({length:N},(_,t)=>
|
||||
Math.max(2,base+trend*t+10*Math.sin(t/w1+seed)+5*Math.sin(t/w2+seed*2)+rnd(t+1,seed)*6));
|
||||
const SERIES=[
|
||||
['LEGACY EDITOR',mk(46,-.62,9,3.7,2),'rgba(8,31,92,.32)'],
|
||||
['BOARDS',mk(26,.10,11,4.2,5),'#7096D1'],
|
||||
['FLOWS',mk(12,.78,10,3.1,8),INK],
|
||||
];
|
||||
obsReveal('stream',s=>{
|
||||
const X0=36,X1=744,CY=138,SC=1.15;
|
||||
const x=t=>X0+t/(N-1)*(X1-X0);
|
||||
const tot=Array.from({length:N},(_,t)=>SERIES.reduce((a,S)=>a+S[1][t],0));
|
||||
// stacked offsets, silhouette baseline: top of stack starts at -total/2
|
||||
const y0=Array.from({length:N},(_,t)=>CY-tot[t]*SC/2);
|
||||
const smooth=pts=>{
|
||||
let d=`M${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;
|
||||
for(let k=1;k<pts.length-1;k++){
|
||||
const p=pts[k],q=pts[k+1];
|
||||
d+=` Q${p[0].toFixed(1)} ${p[1].toFixed(1)} ${((p[0]+q[0])/2).toFixed(1)} ${((p[1]+q[1])/2).toFixed(1)}`;
|
||||
}
|
||||
return d+` L${pts[pts.length-1][0].toFixed(1)} ${pts[pts.length-1][1].toFixed(1)}`;
|
||||
};
|
||||
let run=y0.slice();
|
||||
SERIES.forEach(([name,vals,shade],si)=>{
|
||||
const top=run.map((v,t)=>[x(t),v]);
|
||||
const bot=run.map((v,t)=>[x(t),v+vals[t]*SC]);
|
||||
run=bot.map(p=>p[1]);
|
||||
const d=smooth(top)+' L '+[...bot].reverse().map(p=>`${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' L ')+' Z';
|
||||
const band=el(s,'path',{d,fill:shade,stroke:PAPER,'stroke-width':2,
|
||||
class:'fade',style:`animation-delay:${si*.16}s`});
|
||||
tip(band,`${name} — weekly active accounts, 48 weeks`);
|
||||
// label at the band's widest week — clamped off both ends so the
|
||||
// paper-colored text never spills past the band onto paper (invisible)
|
||||
let wk=4;vals.forEach((v,t)=>{if(t>=4&&t<=N-6&&v>vals[wk])wk=t});
|
||||
const midY=(top[wk][1]+bot[wk][1])/2;
|
||||
const dark=shade===INK||shade==='#334EAC';
|
||||
txt(s,{x:x(wk),y:midY+3,'font-size':9,'font-weight':800,'text-anchor':'middle',
|
||||
fill:dark?PAPER:'#334EAC','letter-spacing':'.06em',
|
||||
class:'fade',style:`animation-delay:${.5+si*.14}s`},name);
|
||||
});
|
||||
// month floor: barcode ticks + labels every 8 weeks
|
||||
const base=252;
|
||||
el(s,'line',{x1:X0-6,y1:base,x2:X1+6,y2:base,stroke:GRID,'stroke-width':1.44,class:'fade'});
|
||||
for(let t=0;t<N;t++)
|
||||
el(s,'line',{x1:x(t),y1:base,x2:x(t),y2:base-(t%8===0?7:4),stroke:'rgba(8,31,92,.24)','stroke-width':1.08,
|
||||
class:'fade',style:`animation-delay:${t*.006}s`});
|
||||
['JAN','MAR','MAY','JUL','SEP','NOV'].forEach((m,k)=>
|
||||
txt(s,{x:x(k*8),y:base+15,'font-size':7.5,'font-weight':600,fill:MUTED,'text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade'},m));
|
||||
txt(s,{x:390,y:288,'font-size':7,'font-weight':600,fill:'#BAD6EB','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
|
||||
'BAND WIDTH = WEEKLY ACTIVES · DARKEST = TODAY’S LEADER · THE RIVER IS THE TOTAL');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ F17 · candlestick ════
|
||||
// K 线的 Mono 化:不用红绿——实心墨 = 收跌,空心(纸底墨框)= 收涨,
|
||||
// 和哑铃图 hollow/ink 的语义一脉。影线是发丝,柱体胶囊圆角。
|
||||
// OHLC 四值都是真实记录,最高/最低两日标数。30 个交易日。
|
||||
(()=>{
|
||||
const N=30;
|
||||
// deterministic random walk with a dip-and-recover arc
|
||||
const DAYS=[];
|
||||
let px=52;
|
||||
for(let d=0;d<N;d++){
|
||||
const drift=d<10?.4:d<18?-1.5:1.3;
|
||||
const open=px;
|
||||
const close=Math.max(30,open+drift+(rnd(d+1,3)-.5)*4.6);
|
||||
const hi=Math.max(open,close)+rnd(d+2,7)*2.6;
|
||||
const lo=Math.min(open,close)-rnd(d+3,11)*2.6;
|
||||
DAYS.push([open,close,hi,lo]);
|
||||
px=close;
|
||||
}
|
||||
obsReveal('candle',s=>{
|
||||
const X0=40,PW=11.2,BASE=258,TOP=44;
|
||||
const vmin=Math.min(...DAYS.map(d=>d[3])),vmax=Math.max(...DAYS.map(d=>d[2]));
|
||||
const mapY=v=>BASE-(v-vmin)/(vmax-vmin)*(BASE-TOP);
|
||||
const x=d=>X0+d*PW+PW/2;
|
||||
// price grid
|
||||
for(let g=0;g<=4;g++){
|
||||
const v=vmin+g/4*(vmax-vmin);
|
||||
el(s,'line',{x1:X0-4,y1:mapY(v),x2:X0+N*PW+4,y2:mapY(v),stroke:'rgba(8,31,92,.12)','stroke-width':1.44,
|
||||
class:'fade',style:`animation-delay:${g*.03}s`});
|
||||
txt(s,{x:X0-8,y:mapY(v)+3,'font-size':7.5,'font-weight':600,fill:MUTED,'text-anchor':'end',
|
||||
class:'fade',style:`animation-delay:${g*.03}s`},'$'+Math.round(v));
|
||||
}
|
||||
let hiD=0,loD=0;
|
||||
DAYS.forEach(([o,c,h,l],d)=>{
|
||||
if(h>DAYS[hiD][2])hiD=d;
|
||||
if(l<DAYS[loD][3])loD=d;
|
||||
});
|
||||
DAYS.forEach(([o,c,h,l],d)=>{
|
||||
const cx=x(d),up=c>=o;
|
||||
const yT=mapY(Math.max(o,c)),yB=mapY(Math.min(o,c));
|
||||
// wick: one hairline, full reach
|
||||
el(s,'line',{x1:cx,y1:mapY(h),x2:cx,y2:mapY(l),stroke:'rgba(8,31,92,.72)','stroke-width':1.26,
|
||||
class:'fade',style:`animation-delay:${d*.03}s`});
|
||||
// body: capsule; hollow = up, ink = down
|
||||
const body=el(s,'rect',{x:cx-3.4,y:yT,width:6.8,height:Math.max(2.5,yB-yT),rx:3,
|
||||
...(up?{fill:PAPER,stroke:INK,'stroke-width':1.98}:{fill:INK}),
|
||||
class:'fade',style:`animation-delay:${.05+d*.03}s`});
|
||||
tip(body,`Day ${d+1} — open $${o.toFixed(1)} · close $${c.toFixed(1)} · high $${h.toFixed(1)} · low $${l.toFixed(1)}`);
|
||||
// extremes, labeled with hairline flags
|
||||
if(d===hiD)txt(s,{x:cx,y:mapY(h)-7,'font-size':8,'font-weight':800,fill:INK,'text-anchor':'middle',
|
||||
style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:1s`,
|
||||
class:'fade'},'$'+Math.round(h));
|
||||
if(d===loD)txt(s,{x:cx,y:mapY(l)+14,'font-size':8,'font-weight':800,fill:'rgba(8,31,92,.72)','text-anchor':'middle',
|
||||
style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:1s`,
|
||||
class:'fade'},'$'+Math.round(l));
|
||||
});
|
||||
// week ticks on the floor
|
||||
el(s,'line',{x1:X0-4,y1:BASE+8,x2:X0+N*PW+4,y2:BASE+8,stroke:GRID,'stroke-width':1.44,class:'fade'});
|
||||
for(let d=0;d<N;d++)
|
||||
el(s,'line',{x1:x(d),y1:BASE+8,x2:x(d),y2:BASE+8-(d%5===0?6:3),stroke:'rgba(8,31,92,.24)','stroke-width':1.08,
|
||||
class:'fade',style:`animation-delay:${d*.008}s`});
|
||||
['W1','W2','W3','W4','W5','W6'].forEach((w,k)=>
|
||||
txt(s,{x:x(k*5),y:BASE+21,'font-size':7,'font-weight':600,fill:MUTED,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade'},w));
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#BAD6EB','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
|
||||
'INK BODY = CLOSED DOWN · HOLLOW = CLOSED UP · WICK = THE DAY’S FULL REACH');
|
||||
});
|
||||
})();
|
||||
})();
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,899 @@
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Lieflat Basics F1–F17 × WIRE</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@6/dist/echarts.min.js"></script>
|
||||
<style>
|
||||
:root{--bg:#F0F0EE;--ink:#1F1E1C;--muted:rgba(31,30,28,.60);--faint:rgba(31,30,28,.32);--grid:rgba(31,30,28,.16)}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);font-family:'Inter','Noto Sans SC',sans-serif;color:var(--ink);padding:40px;-webkit-font-smoothing:antialiased}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:22px;max-width:1100px;margin:0 auto}
|
||||
.card{background:var(--bg);border-radius:24px;padding:28px 28px 20px}
|
||||
.card.wide{grid-column:1/-1}
|
||||
h2{font-weight:700;font-size:16.5px;letter-spacing:-.02em;margin-bottom:3px}
|
||||
.sub{font-size:11.5px;color:var(--muted);margin-bottom:14px}
|
||||
.src{font-size:9.5px;color:var(--faint);margin-top:10px;letter-spacing:.08em;font-weight:500}
|
||||
.badge{display:inline-block;font-size:9px;font-weight:700;letter-spacing:.08em;
|
||||
padding:3px 10px;border-radius:99px;margin-bottom:10px;border:1px dashed var(--faint);color:var(--muted)}
|
||||
svg{width:100%;max-height:330px;display:block;margin:0 auto}
|
||||
.chartbox{width:100%;height:360px}
|
||||
svg text{font-family:'Inter',sans-serif}
|
||||
.pop{transform-box:fill-box;transform-origin:center;animation:pop .5s cubic-bezier(.2,.7,.3,1.3) both}
|
||||
@keyframes pop{from{transform:scale(0)}to{transform:none}}
|
||||
.fade{animation:fade .9s ease both}
|
||||
@keyframes fade{from{opacity:0}}
|
||||
.draw{stroke-dasharray:1;stroke-dashoffset:1;animation:draw 1s cubic-bezier(.4,0,.2,1) both}
|
||||
@keyframes draw{to{stroke-dashoffset:0}}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.pop,.fade{animation:none}
|
||||
.draw{animation:none;stroke-dasharray:none;stroke-dashoffset:0}
|
||||
}
|
||||
.pagehead{max-width:1100px;margin:0 auto 26px;padding:0 4px}
|
||||
.pagehead h1{font-size:22px;letter-spacing:-.02em;font-weight:800}
|
||||
.pagehead p{font-size:12px;color:var(--muted);margin-top:5px;line-height:1.7}
|
||||
.chip{display:inline-block;width:12px;height:12px;border-radius:99px;margin:0 3px -1px 0;box-shadow:inset 0 0 0 1px rgba(0,0,0,.08)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="pagehead">
|
||||
<h1>基础型组 F1–F17 × WIRE</h1>
|
||||
<p><span class="chip" style="background:#F5572F"></span><span class="chip" style="background:#22211F"></span><span class="chip" style="background:#8F8E86"></span><span class="chip" style="background:#C0BFB7"></span><span class="chip" style="background:#DBDAD3"></span> 编辑部红 · 黑灰为体,每张图一个主角元素上红(最大柱/峰值/NET/改版后)。几何、动画、数据与 basics-gallery 一致;彩色版统一加墨(线宽 ×1.8 / 透明度地板 .85,dot heat 除外——它的墨本来就够)。滚入播动画,点击重播。</p>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 柱状</span>
|
||||
<h2>Revenue by plan, rung by rung</h2>
|
||||
<div class="sub">one rung = one $k of MRR · the bar is a ladder you can count</div>
|
||||
<svg id="rungs" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">RUNG BARS · WIRE · BILLING</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 折线</span>
|
||||
<h2>Thirty days of sign-ups</h2>
|
||||
<div class="sub">one dot = one day · hollow = weekend · the barcode floor keeps the calendar honest</div>
|
||||
<svg id="dayline" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">HAIRLINE LINE · WIRE · GROWTH</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 面积</span>
|
||||
<h2>Concurrent users, filled with days</h2>
|
||||
<div class="sub">one hairline = one day, floor to peak · the area is made of days, not paint</div>
|
||||
<svg id="hairarea" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">HAIRLINE AREA · WIRE · LIVE OPS</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 环形</span>
|
||||
<h2>Where the traffic comes from</h2>
|
||||
<div class="sub">one tick = one percent · four sources shade the dial · read it like a clock</div>
|
||||
<svg id="tickdonut" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">TICK DONUT · WIRE · ACQUISITION</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 横向条形</span>
|
||||
<h2>Six teams, shipped and counted</h2>
|
||||
<div class="sub">one tick = one release this quarter · the row is a queue, not a bar</div>
|
||||
<svg id="tickrows" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">TICK ROWS · WIRE · RELEASE LOG</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 分组柱</span>
|
||||
<h2>This year against last, plan by plan</h2>
|
||||
<div class="sub">two ladders per plan · solid rungs = 2026 · faint rungs = 2025 · one rung = $1k</div>
|
||||
<svg id="pairrungs" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">PAIRED RUNGS · WIRE · BILLING YoY</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 堆叠柱</span>
|
||||
<h2>Where each region's revenue sits</h2>
|
||||
<div class="sub">one rung = $1k · three products stack up the ladder, strongest carries the most</div>
|
||||
<svg id="stackrungs" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">STACKED RUNGS · WIRE · REVENUE MIX</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 散点</span>
|
||||
<h2>Price against satisfaction, twelve products</h2>
|
||||
<div class="sub">every dot hangs a plumb line to the floor · read x by the line, y by the height</div>
|
||||
<svg id="plumb" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">PLUMB SCATTER · WIRE · PRODUCT PANEL</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 瀑布</span>
|
||||
<h2>From gross to net, step by step</h2>
|
||||
<div class="sub">one rung = $1k · solid rungs add, dashed rungs take away · the ladder walks down</div>
|
||||
<svg id="fall" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">RUNG WATERFALL · WIRE · P&L</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 热力</span>
|
||||
<h2>When support gets loud</h2>
|
||||
<div class="sub">7 days × 12 hours · dot area = tickets · biggest cell labeled · silence stays visible</div>
|
||||
<svg id="dotheat" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">DOT HEAT · WIRE · SUPPORT DESK</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 进度</span>
|
||||
<h2>How far to the quarter's goal</h2>
|
||||
<div class="sub">one tick = one percent of target · inked = earned · the dial reads left to right</div>
|
||||
<svg id="gauge" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">TICK GAUGE · WIRE · QUARTERLY TARGET</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 哑铃对比</span>
|
||||
<h2>Onboarding, before and after the redesign</h2>
|
||||
<div class="sub">hollow dot = before · solid dot = after · beads on the track = minutes saved, countable</div>
|
||||
<svg id="dumbbell" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">DUMBBELL QUEUE · WIRE · UX TIMING</div>
|
||||
</div>
|
||||
|
||||
<div class="card wide">
|
||||
<span class="badge">LUPI 基础型 · 矩形树图</span>
|
||||
<h2>Where the work went</h2>
|
||||
<div class="sub">1,280 shipped hours · area = effort · fluorescent orange = the single focal workstream</div>
|
||||
<div id="treemap" class="chartbox" role="img" aria-label="Wire treemap of shipped hours nested by product family"></div>
|
||||
<div class="src">NESTED TREEMAP · WIRE · WORK PORTFOLIO</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- F14 · rung histogram -->
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 直方图</span>
|
||||
<h2>Most tickets resolve within six hours</h2>
|
||||
<div class="sub">one rung = one ticket in a hundred · bins of two hours · the ladder shows the pile</div>
|
||||
<svg id="histo" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">RUNG HISTOGRAM · WIRE · SUPPORT DESK</div>
|
||||
</div>
|
||||
|
||||
<!-- F15 · tick box -->
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · 箱线图</span>
|
||||
<h2>Reply times, boxed by plan</h2>
|
||||
<div class="sub">box = the middle half of tickets · paper tick = median · hollow dot = an outlier nobody enjoyed</div>
|
||||
<svg id="boxplot" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">TICK BOX · WIRE · SUPPORT DESK</div>
|
||||
</div>
|
||||
|
||||
<!-- F16 · stream ribbon -->
|
||||
<div class="card wide">
|
||||
<span class="badge">LUPI 基础型 · STREAMGRAPH</span>
|
||||
<h2>Three products trade the same river</h2>
|
||||
<div class="sub">48 weeks of usage share · band width = weekly active accounts · the river swells where the total grows</div>
|
||||
<svg id="stream" viewBox="0 0 840 300" preserveAspectRatio="xMidYMid meet" style="max-height:300px"></svg>
|
||||
<div class="src">STREAM RIBBON · WIRE · PRODUCT ANALYTICS</div>
|
||||
</div>
|
||||
|
||||
<!-- F17 · candlestick -->
|
||||
<div class="card">
|
||||
<span class="badge">LUPI 基础型 · K 线</span>
|
||||
<h2>Six weeks of the token, candle by candle</h2>
|
||||
<div class="sub">body = open to close · wick = the day's full reach · ink = closed down · hollow = closed up</div>
|
||||
<svg id="candle" viewBox="0 0 400 320" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="src">CANDLESTICK · WIRE · MARKET DATA</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
// ── WIRE 角色 token(结构灰一律为文字色的透明档,不引入新色相)──
|
||||
const BG='#F0F0EE',TXT='#1F1E1C',MUT='rgba(31,30,28,.60)',LAB='rgba(31,30,28,.72)',FAINT='rgba(31,30,28,.32)';
|
||||
const FLOOR='rgba(31,30,28,.24)',QUIET='rgba(31,30,28,.15)',TRACK='rgba(31,30,28,.12)',GRID='rgba(31,30,28,.16)';
|
||||
const DATA='#22211F',DATA2='#8F8E86',HERO='#F5572F';
|
||||
const FAINTDATA='#C0BFB7',BEAD='#8F8E86';
|
||||
const CAT4=["#F5572F", "#22211F", "#8F8E86", "#C0BFB7"],CAT3=["#F5572F", "#22211F", "#8F8E86"],CAT3L=["#F5572F", "#22211F", "#6E6D66"],HEAT=["#F5572F", "#6E6D66", "#C0BFB7"];
|
||||
const HEROMODE=true; // 黑体红睛模式:每张图一个主角元素整体走 HERO
|
||||
const NS='http://www.w3.org/2000/svg';
|
||||
const el=(p,t,a)=>{const n=document.createElementNS(NS,t);for(const k in a)n.setAttribute(k,a[k]);p.appendChild(n);return n};
|
||||
const txt=(p,a,s)=>{const n=el(p,'text',a);n.textContent=s;return n};
|
||||
const tip=(n,s)=>{const t=document.createElementNS(NS,'title');t.textContent=s;n.appendChild(t)};
|
||||
const rnd=(i,k)=>Math.abs(((i*73856093)^(k*19349663))%1000)/1000;
|
||||
const D2R=Math.PI/180;
|
||||
const pol=(cx,cy,r,deg)=>[cx+r*Math.cos(deg*D2R),cy+r*Math.sin(deg*D2R)];
|
||||
const obsReveal=(id,fn)=>{
|
||||
const n=document.getElementById(id);
|
||||
const go=()=>{n.innerHTML='';fn(n)};
|
||||
const io=new IntersectionObserver(es=>{if(es[0].isIntersecting){go();io.disconnect()}},{threshold:.3});
|
||||
io.observe(n);
|
||||
n.style.cursor='pointer';
|
||||
n.addEventListener('click',go);
|
||||
};
|
||||
|
||||
// ════ B1 · rung bars ════
|
||||
(()=>{
|
||||
const D=[['FREE',38],['STARTER',27],['PRO',22],['TEAM',16],['SCALE',11],['ENT',7]];
|
||||
obsReveal('rungs',s=>{
|
||||
const x0=i=>56+i*56,base=266,step=5.6,HW=14;
|
||||
D.forEach(([name,v],i)=>{
|
||||
const x=x0(i);
|
||||
const colInk=(HEROMODE&&i===0)?HERO:DATA;
|
||||
for(let k=0;k<v;k++){
|
||||
const y=base-k*step,w=HW-1.5+rnd(k+1,i+2)*3;
|
||||
el(s,'line',{x1:x-w,y1:y,x2:x+w,y2:y,stroke:colInk,'stroke-width':1.8,
|
||||
opacity:.85+rnd(k+2,i+4)*.15,class:'fade',style:`animation-delay:${i*.08+k*.012}s`});
|
||||
if(k%5===4)el(s,'circle',{cx:x+HW+4.5,cy:y,r:.8,fill:FAINT,
|
||||
class:'fade',style:`animation-delay:${i*.08+k*.012}s`});
|
||||
}
|
||||
const topY=base-(v-1)*step;
|
||||
const num=txt(s,{x,y:topY-10,'font-size':11,'font-weight':800,fill:TXT,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${.4+i*.08}s`},v);
|
||||
tip(num,`${name} — $${v}k MRR`);
|
||||
txt(s,{x,y:base+18,'font-size':7.5,'font-weight':700,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.08}s`},name);
|
||||
});
|
||||
el(s,'line',{x1:28,y1:base+4,x2:372,y2:base+4,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:.9s'},
|
||||
'ONE RUNG = $1K · DOT MARKS EVERY FIFTH');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ B2 · hairline line ════
|
||||
(()=>{
|
||||
const N=30;
|
||||
const val=d=>46+22*Math.sin(d/4.6)+14*Math.sin(d/2.1)+rnd(d+1,5)*12;
|
||||
obsReveal('dayline',s=>{
|
||||
const x=d=>30+d*11.7,base=262,map=v=>base-v*2.15;
|
||||
for(let d=0;d<N;d++)
|
||||
el(s,'line',{x1:x(d),y1:base,x2:x(d),y2:base-7,stroke:FLOOR,'stroke-width':.6,
|
||||
class:'fade',style:`animation-delay:${d*.008}s`});
|
||||
el(s,'line',{x1:24,y1:base,x2:376,y2:base,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
const vs=Array.from({length:N},(_,d)=>val(d));
|
||||
const top=[];
|
||||
for(const d of [...vs.keys()].sort((a,b)=>vs[b]-vs[a])){
|
||||
if(top.every(t=>Math.abs(t-d)>=5))top.push(d);
|
||||
if(top.length===2)break;
|
||||
}
|
||||
const pts=vs.map((v,d)=>`${x(d)} ${map(v)}`).join(' L ');
|
||||
el(s,'path',{d:'M'+pts,fill:'none',stroke:DATA,'stroke-width':2.2,pathLength:1,
|
||||
class:'draw',style:'animation-duration:1.2s'});
|
||||
vs.forEach((v,d)=>{
|
||||
const weekend=d%7===5||d%7===6,big=top.includes(d);
|
||||
const dot=el(s,'circle',{cx:x(d),cy:map(v),r:big?5.2:3.1,
|
||||
fill:weekend?BG:(big?HERO:DATA),stroke:big?HERO:DATA,'stroke-width':weekend?1.4:0,
|
||||
class:'pop',style:`animation-delay:${.2+d*.03}s`});
|
||||
tip(dot,`Day ${d+1} — ${Math.round(v)} sign-ups`);
|
||||
if(big)txt(s,{x:x(d),y:map(v)-11,'font-size':9.5,'font-weight':800,fill:TXT,'text-anchor':'middle',
|
||||
style:`paint-order:stroke;stroke:${BG};stroke-width:3px;animation-delay:${1+d*.01}s`,
|
||||
class:'fade'},Math.round(v));
|
||||
});
|
||||
[[0,'JUN 1'],[14,'JUN 15'],[29,'JUN 30']].forEach(([d,m])=>
|
||||
txt(s,{x:x(d),y:base+18,'font-size':7.5,'font-weight':600,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade'},m));
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
|
||||
'ONE DOT = ONE DAY · HOLLOW = WEEKEND');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ B3 · hairline area ════
|
||||
(()=>{
|
||||
const N=45;
|
||||
const val=d=>34+26*Math.sin(d/7.2)+12*Math.sin(d/2.8)+rnd(d+1,3)*16;
|
||||
obsReveal('hairarea',s=>{
|
||||
const x=d=>28+d*7.75,base=262,map=v=>base-v*2.35;
|
||||
el(s,'line',{x1:22,y1:base,x2:378,y2:base,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
const vs=Array.from({length:N},(_,d)=>val(d));
|
||||
const peak=vs.indexOf(Math.max(...vs));
|
||||
vs.forEach((v,d)=>{
|
||||
el(s,'line',{x1:x(d),y1:base,x2:x(d),y2:map(v),
|
||||
stroke:d===peak?HERO:DATA,'stroke-width':d===peak?1.6:1,
|
||||
opacity:d===peak?1:.8+rnd(d+1,7)*.2,
|
||||
class:'fade',style:`animation-delay:${d*.014}s`});
|
||||
});
|
||||
const pts=vs.map((v,d)=>`${x(d)} ${map(v)}`).join(' L ');
|
||||
el(s,'path',{d:'M'+pts,fill:'none',stroke:DATA,'stroke-width':2,pathLength:1,
|
||||
class:'draw',style:'animation-delay:.4s;animation-duration:1.2s'});
|
||||
const pd=el(s,'circle',{cx:x(peak),cy:map(vs[peak]),r:4.2,fill:HERO,
|
||||
class:'pop',style:'animation-delay:1.2s'});
|
||||
tip(pd,`Day ${peak+1} — ${Math.round(vs[peak])}k peak`);
|
||||
txt(s,{x:x(peak),y:map(vs[peak])-11,'font-size':9.5,'font-weight':800,fill:TXT,'text-anchor':'middle',
|
||||
style:`paint-order:stroke;stroke:${BG};stroke-width:3px;animation-delay:1.3s`,
|
||||
class:'fade'},Math.round(vs[peak])+'k');
|
||||
[[0,'MAY'],[22,'JUN'],[44,'JUL']].forEach(([d,m])=>
|
||||
txt(s,{x:x(d),y:base+18,'font-size':7.5,'font-weight':600,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade'},m));
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.3s'},
|
||||
'ONE HAIRLINE = ONE DAY, FLOOR TO PEAK');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ B4 · tick donut ════
|
||||
(()=>{
|
||||
const D=[['ORGANIC',37,CAT4[0]],['PAID',28,CAT4[1]],['REFERRAL',21,CAT4[2]],['SOCIAL',14,CAT4[3]]];
|
||||
obsReveal('tickdonut',s=>{
|
||||
const cx=200,cy=148,R0=64;
|
||||
let k0=0;
|
||||
D.forEach(([name,v,shade],si)=>{
|
||||
for(let k=0;k<v;k++){
|
||||
const idx=k0+k,a=idx*3.6-90;
|
||||
const len=12+rnd(idx+1,si+2)*6;
|
||||
const [x1,y1]=pol(cx,cy,R0,a),[x2,y2]=pol(cx,cy,R0+len,a);
|
||||
el(s,'line',{x1,y1,x2,y2,stroke:shade,'stroke-width':1.9,
|
||||
class:'fade',style:`animation-delay:${idx*.012}s`});
|
||||
if(idx%10===0){
|
||||
const [dx,dy]=pol(cx,cy,R0-5,a);
|
||||
el(s,'circle',{cx:dx,cy:dy,r:.8,fill:FAINT,
|
||||
class:'fade',style:`animation-delay:${idx*.012}s`});
|
||||
}
|
||||
}
|
||||
const mid=(k0+v/2)*3.6-90,[lx,ly]=pol(cx,cy,R0+38,mid),[gx,gy]=pol(cx,cy,R0+20,mid);
|
||||
el(s,'line',{x1:gx,y1:gy,x2:lx,y2:ly,stroke:FAINT,'stroke-width':.7,
|
||||
'stroke-dasharray':'1 3',class:'fade',style:`animation-delay:${.6+si*.1}s`});
|
||||
const anchor=Math.cos(mid*D2R)>0.3?'start':Math.cos(mid*D2R)<-0.3?'end':'middle';
|
||||
const lab=txt(s,{x:lx,y:ly+3,'font-size':8,'font-weight':800,fill:shade,'text-anchor':anchor,
|
||||
'letter-spacing':'.06em',style:`paint-order:stroke;stroke:${BG};stroke-width:3px;animation-delay:${.65+si*.1}s`,
|
||||
class:'fade'},`${name} · ${v}`);
|
||||
tip(lab,`${name} — ${v}% of traffic`);
|
||||
k0+=v;
|
||||
});
|
||||
txt(s,{x:cx,y:cy-2,'font-size':22,'font-weight':800,fill:TXT,'text-anchor':'middle',
|
||||
class:'fade',style:'animation-delay:.9s'},'100');
|
||||
txt(s,{x:cx,y:cy+14,'font-size':7,'font-weight':600,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade',style:'animation-delay:.9s'},'TICKS · ONE = 1%');
|
||||
txt(s,{x:200,y:296,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
|
||||
'TWELVE O’CLOCK IS ZERO · DOT MARKS EVERY TENTH · READS CLOCKWISE');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C1 · tick rows ════
|
||||
(()=>{
|
||||
const D=[['PLATFORM',34],['GROWTH',28],['MOBILE',22],['INFRA',17],['ML',11],['DESIGN',8]];
|
||||
obsReveal('tickrows',s=>{
|
||||
const y0=i=>52+i*44,X0=104,PX=6.9;
|
||||
D.forEach(([name,v],i)=>{
|
||||
const y=y0(i);
|
||||
txt(s,{x:94,y:y+3,'font-size':8,'font-weight':700,fill:LAB,'text-anchor':'end',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.08}s`},name);
|
||||
el(s,'line',{x1:X0,y1:y+9,x2:X0+34*PX,y2:y+9,stroke:GRID,'stroke-width':.6,
|
||||
class:'fade',style:`animation-delay:${i*.08}s`});
|
||||
const rowInk=(HEROMODE&&i===0)?HERO:DATA;
|
||||
for(let k=0;k<v;k++){
|
||||
const x=X0+k*PX+PX/2,h=9+rnd(k+1,i+2)*6;
|
||||
el(s,'line',{x1:x,y1:y+9,x2:x,y2:y+9-h,stroke:rowInk,'stroke-width':1.7,
|
||||
opacity:.85+rnd(k+3,i+5)*.15,class:'fade',style:`animation-delay:${i*.08+k*.012}s`});
|
||||
if(k%5===4)el(s,'circle',{cx:x,cy:y+13,r:.8,fill:FAINT,
|
||||
class:'fade',style:`animation-delay:${i*.08+k*.012}s`});
|
||||
}
|
||||
const lab=txt(s,{x:X0+v*PX+10,y:y+4,'font-size':11,'font-weight':800,fill:TXT,
|
||||
class:'fade',style:`animation-delay:${.4+i*.08}s`},v);
|
||||
tip(lab,`${name} — ${v} releases`);
|
||||
});
|
||||
txt(s,{x:200,y:308,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:.9s'},
|
||||
'ONE TICK = ONE RELEASE · DOT MARKS EVERY FIFTH');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C2 · paired rungs ════
|
||||
(()=>{
|
||||
const D=[['FREE',31,38],['STARTER',22,27],['PRO',16,22],['TEAM',13,16],['ENT',6,9]];
|
||||
obsReveal('pairrungs',s=>{
|
||||
const x0=i=>64+i*66,base=258,step=5.4,HW=10;
|
||||
D.forEach(([name,was,now],i)=>{
|
||||
const xa=x0(i)-13,xb=x0(i)+13;
|
||||
for(let k=0;k<was;k++){
|
||||
const y=base-k*step,w=HW-1.2+rnd(k+1,i+2)*2.4;
|
||||
el(s,'line',{x1:xa-w,y1:y,x2:xa+w,y2:y,stroke:FAINTDATA,'stroke-width':1.8,
|
||||
opacity:.75+rnd(k+2,i+3)*.25,class:'fade',style:`animation-delay:${i*.08+k*.01}s`});
|
||||
}
|
||||
for(let k=0;k<now;k++){
|
||||
const y=base-k*step,w=HW-1.2+rnd(k+1,i+7)*2.4;
|
||||
el(s,'line',{x1:xb-w,y1:y,x2:xb+w,y2:y,stroke:DATA,'stroke-width':1.8,
|
||||
opacity:.85+rnd(k+2,i+8)*.15,class:'fade',style:`animation-delay:${.15+i*.08+k*.01}s`});
|
||||
}
|
||||
const topB=base-(now-1)*step;
|
||||
const num=txt(s,{x:xb,y:topB-9,'font-size':10.5,'font-weight':800,fill:HEROMODE?HERO:TXT,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${.5+i*.08}s`},now);
|
||||
tip(num,`${name} — $${was}k → $${now}k`);
|
||||
txt(s,{x:xa,y:base-(was-1)*step-9,'font-size':8.5,'font-weight':700,fill:FAINTDATA,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${.5+i*.08}s`},was);
|
||||
txt(s,{x:x0(i),y:base+18,'font-size':7.5,'font-weight':700,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.08}s`},name);
|
||||
});
|
||||
el(s,'line',{x1:30,y1:base+4,x2:370,y2:base+4,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1s'},
|
||||
'FAINT = 2025 · SOLID = 2026 · ONE RUNG = $1K');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C3 · stacked rungs ════
|
||||
(()=>{
|
||||
const D=[['NA',[18,11,7]],['EU',[14,9,5]],['APAC',[9,7,6]],['LATAM',[5,4,2]]];
|
||||
const SEG=['CORE','ADD-ONS','SERVICES'];
|
||||
obsReveal('stackrungs',s=>{
|
||||
const x0=i=>72+i*76,base=262,step=5.2,HW=13;
|
||||
D.forEach(([name,segs],i)=>{
|
||||
const x=x0(i);let k0=0;
|
||||
segs.forEach((v,si)=>{
|
||||
for(let k=0;k<v;k++){
|
||||
const y=base-(k0+k+si)*step,w=HW-1.4+rnd(k+1,i*3+si+2)*2.8;
|
||||
el(s,'line',{x1:x-w,y1:y,x2:x+w,y2:y,stroke:CAT3[si],'stroke-width':1.8,
|
||||
opacity:.85+rnd(k+2,i+si+4)*.15,class:'fade',
|
||||
style:`animation-delay:${i*.09+(k0+k)*.012}s`});
|
||||
}
|
||||
const midY=base-(k0+v/2+si)*step;
|
||||
const lab=txt(s,{x:x+HW+7,y:midY+2.5,'font-size':8,'font-weight':800,fill:CAT3L[si],
|
||||
class:'fade',style:`animation-delay:${.5+i*.09+si*.06}s`},v);
|
||||
tip(lab,`${name} ${SEG[si]} — $${v}k`);
|
||||
k0+=v;
|
||||
});
|
||||
const total=segs[0]+segs[1]+segs[2];
|
||||
txt(s,{x,y:base-(k0+2)*step-8,'font-size':10.5,'font-weight':800,fill:TXT,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${.6+i*.09}s`},total);
|
||||
txt(s,{x,y:base+18,'font-size':7.5,'font-weight':700,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.09}s`},name);
|
||||
});
|
||||
el(s,'line',{x1:36,y1:base+4,x2:364,y2:base+4,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
|
||||
'STRONGEST = CORE · MID = ADD-ONS · PALE = SERVICES · ONE RUNG = $1K');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C4 · plumb scatter ════
|
||||
(()=>{
|
||||
const P=[['Editor',72,86],['Boards',58,74],['Docs',44,79],['Chat',38,62],['Flows',66,58],
|
||||
['Vault',82,71],['Pages',28,55],['Sync',52,49],['Grid',88,44],['Views',20,68],
|
||||
['Hub',76,32],['Forms',34,38]];
|
||||
obsReveal('plumb',s=>{
|
||||
const X0=48,X1=368,base=258,mapX=p=>X0+p/100*(X1-X0),mapY=v=>base-(v-20)*2.6;
|
||||
for(let g=0;g<=20;g++){
|
||||
const x=X0+g/20*(X1-X0);
|
||||
el(s,'line',{x1:x,y1:base,x2:x,y2:base-(g%5===0?7:4),stroke:FLOOR,'stroke-width':.6,
|
||||
class:'fade',style:`animation-delay:${g*.01}s`});
|
||||
}
|
||||
el(s,'line',{x1:X0-6,y1:base,x2:X1+6,y2:base,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
txt(s,{x:X0,y:base+16,'font-size':7,'font-weight':600,fill:FAINT,class:'fade'},'CHEAP');
|
||||
txt(s,{x:X1,y:base+16,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'end',
|
||||
class:'fade'},'PREMIUM');
|
||||
const best=P.reduce((a,b)=>b[2]>a[2]?b:a),worst=P.reduce((a,b)=>b[2]<a[2]?b:a);
|
||||
P.forEach(([name,px,sat],i)=>{
|
||||
const x=mapX(px),y=mapY(sat),hero=name===best[0]||name===worst[0];
|
||||
el(s,'line',{x1:x,y1:base,x2:x,y2:y,stroke:FAINTDATA,'stroke-width':1,
|
||||
opacity:.75,class:'fade',style:`animation-delay:${.2+i*.05}s`});
|
||||
const dot=el(s,'circle',{cx:x,cy:y,r:hero?5.4:3.4,fill:hero?HERO:DATA,
|
||||
class:'pop',style:`animation-delay:${.25+i*.05}s`});
|
||||
tip(dot,`${name} — price ${px} · satisfaction ${sat}`);
|
||||
if(hero)txt(s,{x,y:y-10,'font-size':8.5,'font-weight':800,fill:TXT,'text-anchor':'middle',
|
||||
style:`paint-order:stroke;stroke:${BG};stroke-width:3px;animation-delay:${.8}s`,
|
||||
class:'fade'},`${name} · ${sat}`);
|
||||
});
|
||||
txt(s,{x:26,y:mapY(85),'font-size':7,'font-weight':600,fill:FAINT,
|
||||
transform:`rotate(-90 26 ${mapY(85)})`,'text-anchor':'end','letter-spacing':'.08em',class:'fade'},'HAPPIER ↑');
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1s'},
|
||||
'EVERY DOT HANGS A PLUMB LINE · READ X AT THE FLOOR');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C5 · rung waterfall ════
|
||||
(()=>{
|
||||
const D=[['GROSS',42,null],['REFUNDS',-6,null],['COGS',-11,null],['OPS',-8,null],['NET',17,'total']];
|
||||
obsReveal('fall',s=>{
|
||||
const x0=i=>58+i*72,step=5.2,HW=11;
|
||||
let lv=0;const rows=[];
|
||||
D.forEach(([name,v],i)=>{
|
||||
if(name==='GROSS'){rows.push([name,v,0,v]);lv=v}
|
||||
else if(name==='NET'){rows.push([name,lv,0,lv])}
|
||||
else{rows.push([name,v,lv+v,lv]);lv=lv+v}
|
||||
});
|
||||
const base=252,yOf=k=>base-k*step;
|
||||
rows.forEach(([name,v,lo,hi],i)=>{
|
||||
const x=x0(i),isTotal=name==='GROSS'||name==='NET',neg=v<0&&!isTotal;
|
||||
const from=isTotal?0:lo,to=isTotal?v:hi,n=Math.abs(to-from);
|
||||
const colInk=(HEROMODE&&name==='NET')?HERO:DATA;
|
||||
for(let k=0;k<n;k++){
|
||||
const y=yOf(from+k),w=HW-1.2+rnd(k+1,i+2)*2.4;
|
||||
if(neg)el(s,'line',{x1:x-w,y1:y,x2:x+w,y2:y,stroke:DATA2,'stroke-width':1.8,
|
||||
'stroke-dasharray':'2.5 2.5',opacity:.85,class:'fade',
|
||||
style:`animation-delay:${i*.12+k*.014}s`});
|
||||
else el(s,'line',{x1:x-w,y1:y,x2:x+w,y2:y,stroke:colInk,'stroke-width':1.8,
|
||||
opacity:.85+rnd(k+2,i+4)*.15,class:'fade',style:`animation-delay:${i*.12+k*.014}s`});
|
||||
}
|
||||
if(i<rows.length-1){
|
||||
const nx=x0(i+1);
|
||||
const lvl=isTotal?v:(neg?lo:hi);
|
||||
el(s,'line',{x1:x+HW+2,y1:yOf(lvl),x2:nx-HW-2,y2:yOf(lvl),stroke:FAINT,
|
||||
'stroke-width':.7,'stroke-dasharray':'2 3',class:'fade',
|
||||
style:`animation-delay:${.3+i*.12}s`});
|
||||
}
|
||||
const topY=yOf(Math.max(from,to));
|
||||
const num=txt(s,{x,y:topY-8,'font-size':10,'font-weight':800,
|
||||
fill:neg?DATA2:TXT,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${.4+i*.12}s`},(neg?'−':'')+Math.abs(v));
|
||||
tip(num,`${name} — ${neg?'−':''}$${Math.abs(v)}k`);
|
||||
txt(s,{x,y:base+18,'font-size':7.5,'font-weight':700,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.12}s`},name);
|
||||
});
|
||||
el(s,'line',{x1:30,y1:base+4,x2:370,y2:base+4,stroke:GRID,'stroke-width':.8,class:'fade'});
|
||||
txt(s,{x:200,y:302,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
|
||||
'SOLID RUNGS ADD · DASHED RUNGS TAKE AWAY · ONE RUNG = $1K');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C6 · dot heat ════
|
||||
(()=>{
|
||||
const DAY=['MON','TUE','WED','THU','FRI','SAT','SUN'];
|
||||
obsReveal('dotheat',s=>{
|
||||
const x0=j=>64+j*27,y0=i=>58+i*30;
|
||||
let max=0,mi=0,mj=0;
|
||||
const v=(i,j)=>{
|
||||
const day=i<5?1:.32;
|
||||
const hour=Math.exp(-((j-4.6)**2)/7)+.7*Math.exp(-((j-8.4)**2)/5);
|
||||
const raw=day*hour*22*(0.6+rnd(i*12+j+1,j+3)*.8);
|
||||
return Math.round(raw);
|
||||
};
|
||||
for(let i=0;i<7;i++)for(let j=0;j<12;j++){const t=v(i,j);if(t>max){max=t;mi=i;mj=j}}
|
||||
for(let i=0;i<7;i++){
|
||||
txt(s,{x:50,y:y0(i)+3,'font-size':7.5,'font-weight':700,fill:LAB,'text-anchor':'end',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${i*.05}s`},DAY[i]);
|
||||
for(let j=0;j<12;j++){
|
||||
const t=v(i,j),x=x0(j),y=y0(i);
|
||||
if(!t){el(s,'circle',{cx:x,cy:y,r:.8,fill:QUIET,
|
||||
class:'pop',style:`animation-delay:${i*.05+j*.015}s`});continue}
|
||||
const hero=i===mi&&j===mj;
|
||||
const dot=el(s,'circle',{cx:x,cy:y,r:1.2+Math.sqrt(t)*2.1,
|
||||
fill:t>max*.66?HEAT[0]:t>max*.33?HEAT[1]:HEAT[2],
|
||||
class:'pop',style:`animation-delay:${i*.05+j*.015}s`});
|
||||
tip(dot,`${DAY[i]} ${8+j}:00 — ${t} tickets`);
|
||||
if(hero)el(s,'circle',{cx:x,cy:y,r:1.2+Math.sqrt(t)*2.1+3.4,fill:'none',
|
||||
stroke:HERO,'stroke-width':1,'stroke-dasharray':'2 3',
|
||||
class:'fade',style:'animation-delay:1s'});
|
||||
}
|
||||
}
|
||||
for(let j=0;j<12;j+=2)
|
||||
txt(s,{x:x0(j),y:y0(6)+26,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${j*.02}s`},(8+j)+':00');
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
|
||||
`DOT AREA = TICKETS · DASHED RING = THE PEAK, ${max} · TINY DOT = A QUIET HOUR`);
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C7 · tick gauge ════
|
||||
(()=>{
|
||||
const GOAL=73;
|
||||
obsReveal('gauge',s=>{
|
||||
const cx=200,cy=190,R0=104,A0=-195,SW=210;
|
||||
for(let k=0;k<100;k++){
|
||||
const a=A0+k/100*SW,inked=k<GOAL;
|
||||
const len=inked?15+rnd(k+1,3)*6:5+rnd(k+1,7)*2.5;
|
||||
const [x1,y1]=pol(cx,cy,R0,a),[x2,y2]=pol(cx,cy,R0+len,a);
|
||||
el(s,'line',{x1,y1,x2,y2,stroke:inked?DATA:FLOOR,'stroke-width':inked?1.9:.6,
|
||||
class:'fade',style:`animation-delay:${k*.012}s`});
|
||||
}
|
||||
[25,50,75,100].forEach(m=>{
|
||||
const a=A0+m/100*SW,[dx,dy]=pol(cx,cy,R0-7,a),[tx2,ty2]=pol(cx,cy,R0-19,a);
|
||||
el(s,'circle',{cx:dx,cy:dy,r:1,fill:FAINT,class:'fade',style:'animation-delay:.8s'});
|
||||
txt(s,{x:tx2,y:ty2+3,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
class:'fade',style:'animation-delay:.85s'},m);
|
||||
});
|
||||
const aT=A0+GOAL/100*SW,[ex,ey]=pol(cx,cy,R0+20,aT);
|
||||
el(s,'circle',{cx:ex,cy:ey,r:3.2,fill:HERO,class:'pop',style:'animation-delay:1.1s'});
|
||||
const num=txt(s,{x:cx,y:cy-4,'font-size':34,'font-weight':800,fill:TXT,'text-anchor':'middle',
|
||||
class:'fade',style:'animation-delay:1s'},GOAL+'%');
|
||||
tip(num,`$730k of the $1M quarter target`);
|
||||
txt(s,{x:cx,y:cy+16,'font-size':8,'font-weight':600,fill:MUT,'text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade',style:'animation-delay:1.05s'},'27 TICKS TO GO');
|
||||
txt(s,{x:200,y:300,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
|
||||
'ONE TICK = 1% OF TARGET · INKED = EARNED');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C8 · dumbbell queue ════
|
||||
(()=>{
|
||||
const D=[['INVITE FLOW',14,6],['FIRST BOARD',19,9],['IMPORT DATA',26,13],['TEAM SETUP',31,21],['GO LIVE',38,30]];
|
||||
obsReveal('dumbbell',s=>{
|
||||
const y0=i=>56+i*46,X0=118,X1=364,mapX=v=>X0+(v-4)/36*(X1-X0);
|
||||
D.forEach(([name,was,now],i)=>{
|
||||
const y=y0(i),xa=mapX(was),xb=mapX(now);
|
||||
txt(s,{x:108,y:y+3,'font-size':7.5,'font-weight':700,fill:LAB,'text-anchor':'end',
|
||||
'letter-spacing':'.06em',class:'fade',style:`animation-delay:${i*.08}s`},name);
|
||||
el(s,'line',{x1:X0-6,y1:y,x2:X1+6,y2:y,stroke:TRACK,'stroke-width':.7,
|
||||
class:'fade',style:`animation-delay:${i*.08}s`});
|
||||
const n=was-now;
|
||||
for(let k=0;k<n;k++){
|
||||
const t=(k+.5)/n,x=xb+t*(xa-xb),yy=y+(rnd(k+1,i+3)-.5)*2.6;
|
||||
el(s,'circle',{cx:x,cy:yy,r:2.1+rnd(k+2,i+4)*.9,fill:BEAD,opacity:.95,
|
||||
class:'pop',style:`animation-delay:${.3+i*.08+k*.03}s`});
|
||||
}
|
||||
el(s,'circle',{cx:xa,cy:y,r:4.8,fill:BG,stroke:DATA,'stroke-width':1.7,
|
||||
class:'pop',style:`animation-delay:${.2+i*.08}s`});
|
||||
const after=el(s,'circle',{cx:xb,cy:y,r:5.2,fill:HERO,
|
||||
class:'pop',style:`animation-delay:${.6+i*.08}s`});
|
||||
tip(after,`${name} — ${was} min → ${now} min`);
|
||||
txt(s,{x:xa+10,y:y-8,'font-size':8.5,'font-weight':700,fill:FAINTDATA,
|
||||
class:'fade',style:`animation-delay:${.3+i*.08}s`},was);
|
||||
txt(s,{x:xb-10,y:y-8,'font-size':10,'font-weight':800,fill:TXT,'text-anchor':'end',
|
||||
class:'fade',style:`animation-delay:${.7+i*.08}s`},now);
|
||||
});
|
||||
txt(s,{x:X0,y:290,'font-size':7,'font-weight':600,fill:FAINT,class:'fade'},'FASTER ←');
|
||||
txt(s,{x:X1,y:290,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'end',
|
||||
class:'fade'},'MINUTES');
|
||||
txt(s,{x:200,y:308,'font-size':7,'font-weight':600,fill:FAINT,'text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1s'},
|
||||
'ONE BEAD = ONE MINUTE SAVED · HOLLOW = BEFORE · SOLID = AFTER');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ C9 · nested treemap ════
|
||||
(()=>{
|
||||
const TOTAL=1280;
|
||||
const leaf=(name,value,color,labelColor=BG)=>({name,value,itemStyle:{color},label:{color:labelColor}});
|
||||
const TREE_DATA=[
|
||||
{name:'PRODUCT · 50%',children:[leaf('Core app',260,HERO),leaf('Collaboration',190,DATA),leaf('Search',110,DATA),leaf('Mobile',80,DATA)]},
|
||||
{name:'PLATFORM · 31%',children:[leaf('AI systems',170,DATA2,TXT),leaf('Data infra',120,DATA2,TXT),leaf('APIs',105,DATA2,TXT)]},
|
||||
{name:'GROWTH · 19%',children:[leaf('Acquisition',90,FAINTDATA,TXT),leaf('Retention',80,FAINTDATA,TXT),leaf('Onboarding',75,FAINTDATA,TXT)]},
|
||||
];
|
||||
const host=document.getElementById('treemap');let chart;
|
||||
const draw=()=>{chart=chart||echarts.init(host,null,{renderer:'svg'});chart.clear();chart.setOption({
|
||||
animationDuration:900,animationEasing:'quarticOut',aria:{enabled:true},
|
||||
tooltip:{backgroundColor:BG,borderColor:TXT,borderWidth:1,textStyle:{color:TXT,fontSize:12},
|
||||
formatter:i=>`${i.treePathInfo.slice(1).map(d=>d.name.replace(/ · .*$/,'')).join(' / ')}<br><b>${i.value} hours</b> · ${(i.value/TOTAL*100).toFixed(1)}%`},
|
||||
series:[{type:'treemap',data:TREE_DATA,top:0,right:0,bottom:0,left:0,roam:false,nodeClick:false,breadcrumb:{show:false},leafDepth:2,squareRatio:1.2,
|
||||
label:{show:true,position:'insideTopLeft',padding:[10,8],color:TXT,fontSize:12,fontWeight:700,lineHeight:18,
|
||||
formatter:p=>p.treePathInfo.length<=1?'':p.treePathInfo.length===2?p.name:`${p.name}\n${p.value} h`},
|
||||
upperLabel:{show:true,height:32,padding:[0,10],color:TXT,fontSize:10,fontWeight:700,backgroundColor:BG},
|
||||
itemStyle:{borderColor:BG,borderWidth:2,gapWidth:2},emphasis:{focus:'ancestor',itemStyle:{borderColor:TXT,borderWidth:1.5}},
|
||||
levels:[{itemStyle:{borderWidth:0,gapWidth:5}},{upperLabel:{show:true,height:32},itemStyle:{borderColor:BG,borderWidth:3,gapWidth:3}},{upperLabel:{show:false},itemStyle:{borderColor:BG,borderWidth:2,gapWidth:2}}]}]});};
|
||||
const io=new IntersectionObserver(es=>{if(es[0].isIntersecting){draw();io.disconnect()}},{threshold:.25});
|
||||
io.observe(host);host.style.cursor='pointer';host.addEventListener('click',draw);addEventListener('resize',()=>chart?.resize());
|
||||
})();
|
||||
|
||||
// ── New chart-family helpers (scoped to the added templates) ──
|
||||
(()=>{
|
||||
const INK='#22211F',PAPER='#F0F0EE',MUTED='rgba(31,30,28,.60)',GRID='rgba(31,30,28,.16)';
|
||||
const NS='http://www.w3.org/2000/svg';
|
||||
const el=(p,t,a)=>{const n=document.createElementNS(NS,t);for(const k in a)n.setAttribute(k,a[k]);p.appendChild(n);return n};
|
||||
const txt=(p,a,s)=>{const n=el(p,'text',a);n.textContent=s;return n};
|
||||
const tip=(n,s)=>{const t=document.createElementNS(NS,'title');t.textContent=s;n.appendChild(t)};
|
||||
const rnd=(i,k)=>Math.abs(((i*73856093)^(k*19349663))%1000)/1000;
|
||||
|
||||
// ════ F14 · rung histogram ════
|
||||
// 直方图的 Lupi Basics 皮:连续数值分箱,一箱一把梯子,1 档 = 1 张工单
|
||||
// (百分之一)。箱界写在梯脚之间——直方图的箱是区间不是类目,
|
||||
// 这是它和 F1 Rung Bars 的本质区别。峰值箱标数,中位数落点小旗标注。
|
||||
(()=>{
|
||||
const BIN=[6,14,22,19,13,9,6,4,3,2,1,1]; // 2h bins, sums to 100
|
||||
obsReveal('histo',s=>{
|
||||
const X0=44,PW=27.5,base=252,step=5.4,HW=10.5;
|
||||
BIN.forEach((v,i)=>{
|
||||
const x=X0+i*PW+PW/2;
|
||||
for(let k=0;k<v;k++){
|
||||
const y=base-k*step,w=HW-1.4+rnd(k+1,i+2)*2.8;
|
||||
el(s,'line',{x1:x-w,y1:y,x2:x+w,y2:y,stroke:INK,'stroke-width':1.8,
|
||||
opacity:.55+rnd(k+2,i+4)*.45,class:'fade',style:`animation-delay:${i*.06+k*.012}s`});
|
||||
if(k%5===4)el(s,'circle',{cx:x+HW+3.5,cy:y,r:.7,fill:'rgba(31,30,28,.32)',
|
||||
class:'fade',style:`animation-delay:${i*.06+k*.012}s`});
|
||||
}
|
||||
});
|
||||
// peak label
|
||||
const pk=BIN.indexOf(Math.max(...BIN));
|
||||
const px=X0+pk*PW+PW/2;
|
||||
// 一图一红:红给中位旗(half resolved 才是结论),峰值数走墨
|
||||
const num=txt(s,{x:px,y:base-(BIN[pk]-1)*step-10,'font-size':11,'font-weight':800,fill:INK,
|
||||
'text-anchor':'middle',class:'fade',style:'animation-delay:.8s'},BIN[pk]);
|
||||
tip(num,`${BIN[pk]} of 100 tickets took ${pk*2}–${pk*2+2}h`);
|
||||
// bin edges: the continuous axis is the histogram's identity
|
||||
el(s,'line',{x1:X0-4,y1:base+4,x2:X0+12*PW+4,y2:base+4,stroke:GRID,'stroke-width':1.44,class:'fade'});
|
||||
for(let e=0;e<=12;e++){
|
||||
const x=X0+e*PW;
|
||||
el(s,'line',{x1:x,y1:base+4,x2:x,y2:base+(e%2===0?11:8),stroke:'rgba(31,30,28,.24)','stroke-width':1.08,
|
||||
class:'fade',style:`animation-delay:${e*.02}s`});
|
||||
if(e%2===0)txt(s,{x,y:base+21,'font-size':7,'font-weight':600,fill:MUTED,'text-anchor':'middle',
|
||||
class:'fade',style:`animation-delay:${e*.02}s`},e*2+'h');
|
||||
}
|
||||
// median flag: cumulative crosses 50 inside bin 2
|
||||
let acc=0,mb=0;for(let i=0;i<12;i++){acc+=BIN[i];if(acc>=50){mb=i;break}}
|
||||
const mx=X0+mb*PW+PW*.7;
|
||||
el(s,'line',{x1:mx,y1:base+4,x2:mx,y2:base-BIN[mb]*step-16,stroke:'#F5572F','stroke-width':1.62,
|
||||
'stroke-dasharray':'2 4',class:'fade',style:'animation-delay:1s'});
|
||||
txt(s,{x:mx+4,y:base-BIN[mb]*step-20,'font-size':7.5,'font-weight':800,fill:'#F5572F',
|
||||
class:'fade',style:'animation-delay:1.1s'},'HALF RESOLVED BY HERE');
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#C0BFB7','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
|
||||
'ONE RUNG = ONE TICKET IN A HUNDRED · BINS OF TWO HOURS · DOT MARKS EVERY FIFTH');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ F15 · tick box ════
|
||||
// 箱线图的 Basics 皮:发丝须线 + 胶囊箱体(IQR),纸色横档 = 中位数,
|
||||
// 空心点 = 离群值。箱体明度按中位数快慢排:越快越黑。
|
||||
(()=>{
|
||||
const G=[['ENT',[.4,.9,1.5,2.6,4.4],[6.2]],
|
||||
['PRO',[.8,2.1,3.3,5,7.8],[10.5]],
|
||||
['STARTER',[1.5,3.8,6.1,8.9,13.2],[16.8]],
|
||||
['FREE',[2.2,6,9.4,13.8,19.6],[22.1,23.5]]];
|
||||
const SHADE=[INK,'#6E6D66','#8F8E86','#C0BFB7'];
|
||||
obsReveal('boxplot',s=>{
|
||||
const TOP=42,BASE=260,MAXH=24,mapY=v=>BASE-v/MAXH*(BASE-TOP);
|
||||
const colX=g=>78+g*84,BW=24;
|
||||
[0,6,12,18,24].forEach(h=>{
|
||||
el(s,'line',{x1:36,y1:mapY(h),x2:378,y2:mapY(h),stroke:'rgba(31,30,28,.12)','stroke-width':1.44,
|
||||
class:'fade',style:`animation-delay:${h*.008}s`});
|
||||
txt(s,{x:30,y:mapY(h)+3,'font-size':7.5,'font-weight':600,fill:MUTED,'text-anchor':'end',
|
||||
class:'fade',style:`animation-delay:${h*.008}s`},h+'h');
|
||||
});
|
||||
G.forEach(([name,[mn,q1,md,q3,mx],outs],g)=>{
|
||||
const x=colX(g);
|
||||
el(s,'line',{x1:x,y1:mapY(mn),x2:x,y2:mapY(mx),stroke:'#8F8E86','stroke-width':1.44,
|
||||
pathLength:1,class:'draw',style:`animation-delay:${g*.12}s;animation-duration:.6s`});
|
||||
[mn,mx].forEach(vv=>el(s,'line',{x1:x-7,y1:mapY(vv),x2:x+7,y2:mapY(vv),
|
||||
stroke:'#8F8E86','stroke-width':1.8,class:'fade',style:`animation-delay:${.2+g*.12}s`}));
|
||||
const box=el(s,'rect',{x:x-BW/2,y:mapY(q3),width:BW,height:mapY(q1)-mapY(q3),rx:9,
|
||||
fill:SHADE[g],class:'pop',style:`animation-delay:${.15+g*.12}s`});
|
||||
tip(box,`${name} — half of tickets answered in ${q1}–${q3}h`);
|
||||
el(s,'line',{x1:x-BW/2+3,y1:mapY(md),x2:x+BW/2-3,y2:mapY(md),stroke:PAPER,'stroke-width':2.2,
|
||||
class:'fade',style:`animation-delay:${.5+g*.12}s`});
|
||||
// 红只给一个主角:最慢的 FREE 是这张图的故事,其余中位数走墨色
|
||||
const slow=g===G.length-1;
|
||||
txt(s,{x:x+BW/2+6,y:mapY(md)+3,'font-size':slow?11:9.5,'font-weight':800,
|
||||
fill:slow?'#F5572F':INK,
|
||||
class:'fade',style:`animation-delay:${.6+g*.12}s`},md.toFixed(1)+'h');
|
||||
outs.forEach((o,k)=>{
|
||||
const d=el(s,'circle',{cx:x+(rnd(k+1,g+3)-.5)*8,cy:mapY(o),r:2.6,fill:PAPER,
|
||||
stroke:'rgba(31,30,28,.72)','stroke-width':1.98,class:'pop',style:`animation-delay:${.7+g*.12+k*.06}s`});
|
||||
tip(d,`${name} outlier — ${o}h`);
|
||||
});
|
||||
txt(s,{x,y:BASE+18,'font-size':7.5,'font-weight':700,fill:MUTED,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade',style:`animation-delay:${g*.12}s`},name);
|
||||
});
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#C0BFB7','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.2s'},
|
||||
'BOX = MIDDLE HALF · PAPER TICK = MEDIAN · RED = THE SLOWEST PLAN · DARKEST = FASTEST');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ F16 · stream ribbon ════
|
||||
// Streamgraph / ThemeRiver:多系列构成随时间此消彼长。轮廓对称于中轴
|
||||
// (silhouette 基线),带宽 = 该系列当周量,纸色缝分带(treemap 同法)。
|
||||
// 三带明度按当前份额排:现任霸主最黑。带名标在各自最宽处。
|
||||
(()=>{
|
||||
const N=48;
|
||||
const mk=(base,trend,w1,w2,seed)=>Array.from({length:N},(_,t)=>
|
||||
Math.max(2,base+trend*t+10*Math.sin(t/w1+seed)+5*Math.sin(t/w2+seed*2)+rnd(t+1,seed)*6));
|
||||
// 现任霸主是主角:FLOWS 上红,其余灰阶退后
|
||||
const SERIES=[
|
||||
['LEGACY EDITOR',mk(46,-.62,9,3.7,2),'rgba(31,30,28,.32)'],
|
||||
['BOARDS',mk(26,.10,11,4.2,5),'#8F8E86'],
|
||||
['FLOWS',mk(12,.78,10,3.1,8),'#F5572F'],
|
||||
];
|
||||
obsReveal('stream',s=>{
|
||||
const X0=36,X1=744,CY=138,SC=1.15;
|
||||
const x=t=>X0+t/(N-1)*(X1-X0);
|
||||
const tot=Array.from({length:N},(_,t)=>SERIES.reduce((a,S)=>a+S[1][t],0));
|
||||
// stacked offsets, silhouette baseline: top of stack starts at -total/2
|
||||
const y0=Array.from({length:N},(_,t)=>CY-tot[t]*SC/2);
|
||||
const smooth=pts=>{
|
||||
let d=`M${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;
|
||||
for(let k=1;k<pts.length-1;k++){
|
||||
const p=pts[k],q=pts[k+1];
|
||||
d+=` Q${p[0].toFixed(1)} ${p[1].toFixed(1)} ${((p[0]+q[0])/2).toFixed(1)} ${((p[1]+q[1])/2).toFixed(1)}`;
|
||||
}
|
||||
return d+` L${pts[pts.length-1][0].toFixed(1)} ${pts[pts.length-1][1].toFixed(1)}`;
|
||||
};
|
||||
let run=y0.slice();
|
||||
SERIES.forEach(([name,vals,shade],si)=>{
|
||||
const top=run.map((v,t)=>[x(t),v]);
|
||||
const bot=run.map((v,t)=>[x(t),v+vals[t]*SC]);
|
||||
run=bot.map(p=>p[1]);
|
||||
const d=smooth(top)+' L '+[...bot].reverse().map(p=>`${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' L ')+' Z';
|
||||
const band=el(s,'path',{d,fill:shade,stroke:PAPER,'stroke-width':2,
|
||||
class:'fade',style:`animation-delay:${si*.16}s`});
|
||||
tip(band,`${name} — weekly active accounts, 48 weeks`);
|
||||
// label at the band's widest week — clamped off both ends so the
|
||||
// paper-colored text never spills past the band onto paper (invisible)
|
||||
let wk=4;vals.forEach((v,t)=>{if(t>=4&&t<=N-6&&v>vals[wk])wk=t});
|
||||
const midY=(top[wk][1]+bot[wk][1])/2;
|
||||
const dark=shade===INK||shade==='#6E6D66'||shade==='#F5572F';
|
||||
txt(s,{x:x(wk),y:midY+3,'font-size':9,'font-weight':800,'text-anchor':'middle',
|
||||
fill:dark?PAPER:'#6E6D66','letter-spacing':'.06em',
|
||||
class:'fade',style:`animation-delay:${.5+si*.14}s`},name);
|
||||
});
|
||||
// month floor: barcode ticks + labels every 8 weeks
|
||||
const base=252;
|
||||
el(s,'line',{x1:X0-6,y1:base,x2:X1+6,y2:base,stroke:GRID,'stroke-width':1.44,class:'fade'});
|
||||
for(let t=0;t<N;t++)
|
||||
el(s,'line',{x1:x(t),y1:base,x2:x(t),y2:base-(t%8===0?7:4),stroke:'rgba(31,30,28,.24)','stroke-width':1.08,
|
||||
class:'fade',style:`animation-delay:${t*.006}s`});
|
||||
['JAN','MAR','MAY','JUL','SEP','NOV'].forEach((m,k)=>
|
||||
txt(s,{x:x(k*8),y:base+15,'font-size':7.5,'font-weight':600,fill:MUTED,'text-anchor':'middle',
|
||||
'letter-spacing':'.1em',class:'fade'},m));
|
||||
txt(s,{x:390,y:288,'font-size':7,'font-weight':600,fill:'#C0BFB7','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
|
||||
'BAND WIDTH = WEEKLY ACTIVES · RED = TODAY’S LEADER · THE RIVER IS THE TOTAL');
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ F17 · candlestick ════
|
||||
// K 线的 Mono 化:不用红绿——实心墨 = 收跌,空心(纸底墨框)= 收涨,
|
||||
// 和哑铃图 hollow/ink 的语义一脉。影线是发丝,柱体胶囊圆角。
|
||||
// OHLC 四值都是真实记录,最高/最低两日标数。30 个交易日。
|
||||
(()=>{
|
||||
const N=30;
|
||||
// deterministic random walk with a dip-and-recover arc
|
||||
const DAYS=[];
|
||||
let px=52;
|
||||
for(let d=0;d<N;d++){
|
||||
const drift=d<10?.4:d<18?-1.5:1.3;
|
||||
const open=px;
|
||||
const close=Math.max(30,open+drift+(rnd(d+1,3)-.5)*4.6);
|
||||
const hi=Math.max(open,close)+rnd(d+2,7)*2.6;
|
||||
const lo=Math.min(open,close)-rnd(d+3,11)*2.6;
|
||||
DAYS.push([open,close,hi,lo]);
|
||||
px=close;
|
||||
}
|
||||
obsReveal('candle',s=>{
|
||||
const X0=40,PW=11.2,BASE=258,TOP=44;
|
||||
const vmin=Math.min(...DAYS.map(d=>d[3])),vmax=Math.max(...DAYS.map(d=>d[2]));
|
||||
const mapY=v=>BASE-(v-vmin)/(vmax-vmin)*(BASE-TOP);
|
||||
const x=d=>X0+d*PW+PW/2;
|
||||
// price grid
|
||||
for(let g=0;g<=4;g++){
|
||||
const v=vmin+g/4*(vmax-vmin);
|
||||
el(s,'line',{x1:X0-4,y1:mapY(v),x2:X0+N*PW+4,y2:mapY(v),stroke:'rgba(31,30,28,.12)','stroke-width':1.44,
|
||||
class:'fade',style:`animation-delay:${g*.03}s`});
|
||||
txt(s,{x:X0-8,y:mapY(v)+3,'font-size':7.5,'font-weight':600,fill:MUTED,'text-anchor':'end',
|
||||
class:'fade',style:`animation-delay:${g*.03}s`},'$'+Math.round(v));
|
||||
}
|
||||
let hiD=0,loD=0;
|
||||
DAYS.forEach(([o,c,h,l],d)=>{
|
||||
if(h>DAYS[hiD][2])hiD=d;
|
||||
if(l<DAYS[loD][3])loD=d;
|
||||
});
|
||||
DAYS.forEach(([o,c,h,l],d)=>{
|
||||
const cx=x(d),up=c>=o;
|
||||
const yT=mapY(Math.max(o,c)),yB=mapY(Math.min(o,c));
|
||||
// wick: one hairline, full reach
|
||||
el(s,'line',{x1:cx,y1:mapY(h),x2:cx,y2:mapY(l),stroke:'rgba(31,30,28,.72)','stroke-width':1.26,
|
||||
class:'fade',style:`animation-delay:${d*.03}s`});
|
||||
// body: capsule; hollow = up, ink = down
|
||||
const body=el(s,'rect',{x:cx-3.4,y:yT,width:6.8,height:Math.max(2.5,yB-yT),rx:3,
|
||||
...(up?{fill:PAPER,stroke:INK,'stroke-width':1.98}:{fill:INK}),
|
||||
class:'fade',style:`animation-delay:${.05+d*.03}s`});
|
||||
tip(body,`Day ${d+1} — open $${o.toFixed(1)} · close $${c.toFixed(1)} · high $${h.toFixed(1)} · low $${l.toFixed(1)}`);
|
||||
// extremes, labeled with hairline flags
|
||||
// 一图一红:这段行情的故事是探底,红只给最低价,最高价走墨
|
||||
if(d===hiD)txt(s,{x:cx,y:mapY(h)-7,'font-size':8,'font-weight':800,fill:INK,'text-anchor':'middle',
|
||||
style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:1s`,
|
||||
class:'fade'},'$'+Math.round(h));
|
||||
if(d===loD)txt(s,{x:cx,y:mapY(l)+14,'font-size':9.5,'font-weight':800,fill:'#F5572F','text-anchor':'middle',
|
||||
style:`paint-order:stroke;stroke:${PAPER};stroke-width:3px;animation-delay:1s`,
|
||||
class:'fade'},'$'+Math.round(l));
|
||||
});
|
||||
// week ticks on the floor
|
||||
el(s,'line',{x1:X0-4,y1:BASE+8,x2:X0+N*PW+4,y2:BASE+8,stroke:GRID,'stroke-width':1.44,class:'fade'});
|
||||
for(let d=0;d<N;d++)
|
||||
el(s,'line',{x1:x(d),y1:BASE+8,x2:x(d),y2:BASE+8-(d%5===0?6:3),stroke:'rgba(31,30,28,.24)','stroke-width':1.08,
|
||||
class:'fade',style:`animation-delay:${d*.008}s`});
|
||||
['W1','W2','W3','W4','W5','W6'].forEach((w,k)=>
|
||||
txt(s,{x:x(k*5),y:BASE+21,'font-size':7,'font-weight':600,fill:MUTED,'text-anchor':'middle',
|
||||
'letter-spacing':'.08em',class:'fade'},w));
|
||||
txt(s,{x:200,y:306,'font-size':7,'font-weight':600,fill:'#C0BFB7','text-anchor':'middle',
|
||||
'letter-spacing':'.12em',class:'fade',style:'animation-delay:1.1s'},
|
||||
'INK BODY = CLOSED DOWN · HOLLOW = CLOSED UP · WICK = THE DAY’S FULL REACH');
|
||||
});
|
||||
})();
|
||||
})();
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,112 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>椰林绿 — Circular Graph, Dense</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@6/dist/echarts.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--bg:#F0EFEB;--dark:#28311F}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);font-family:'Inter',sans-serif;padding:40px;-webkit-font-smoothing:antialiased;
|
||||
display:flex;justify-content:center}
|
||||
.card{background:var(--dark);color:#F0EFE9;border-radius:24px;padding:34px 34px 24px;
|
||||
width:min(1100px,100%)}
|
||||
h2{font-weight:700;font-size:19px;letter-spacing:-.02em;margin-bottom:4px}
|
||||
.sub{font-size:12px;color:#92A763;margin-bottom:10px}
|
||||
.src{font-size:9.5px;color:#5A6B47;margin-top:12px;letter-spacing:.08em;font-weight:500}
|
||||
#ch{height:78vh;min-height:620px;cursor:pointer}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>Six hundred collaborations, one ring</h2>
|
||||
<div class="sub">60 repos · line = shared contributors · hover a repo to light up its world · click to replay · 绿黄阶 8 档编码集群离核心的远近(亮 = 核心),hover 高亮走麦黄。</div>
|
||||
<div id="ch"></div>
|
||||
<div class="src">CIRCULAR GRAPH · DENSE · SOURCE · GIT LOG · PALM</div>
|
||||
</div>
|
||||
<script>
|
||||
const INK='#28311F',PAPER='#F0EFE9';
|
||||
const rnd=(i,k)=>((i*73856093)^(k*19349663))%1000/1000; // deterministic
|
||||
|
||||
// ── 60 repos in 5 org clusters; cluster = shade family ──
|
||||
const ORGS=[
|
||||
['platform',16,'#F0EFE9'], // paper — the core
|
||||
['product',14,'#C8CAA8'],
|
||||
['data',12,'#AFB885'],
|
||||
['infra',10,'#7EA056'],
|
||||
['labs',8,'#4F7D3E'],
|
||||
];
|
||||
const nodes=[];
|
||||
ORGS.forEach(([org,count],oi)=>{
|
||||
for(let i=0;i<count;i++){
|
||||
// activity: a few giants per cluster, long tail of small repos
|
||||
const heavy=i<2?30+rnd(oi+1,i+2)*45:4+rnd(oi+3,i+7)*16;
|
||||
nodes.push({
|
||||
name:org+'/'+org.slice(0,2)+'-'+String(i+1).padStart(2,'0'),
|
||||
org:oi,value:Math.round(heavy),
|
||||
});
|
||||
}
|
||||
});
|
||||
// links: dense inside a cluster, sparser across; weight = shared contributors
|
||||
const links=[];
|
||||
const idx={};nodes.forEach((n,i)=>idx[n.name]=i);
|
||||
nodes.forEach((a,i)=>{
|
||||
nodes.forEach((b,j)=>{
|
||||
if(j<=i)return;
|
||||
const same=a.org===b.org;
|
||||
const p=same?.16:.045;
|
||||
if(rnd(i+1,j+2)<p){
|
||||
links.push({source:i,target:j,
|
||||
w:1+Math.round(rnd(i+2,j+3)*6),same});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const g=echarts.init(document.getElementById('ch'));
|
||||
const opt={
|
||||
animationDuration:1800,animationEasing:'quarticOut',
|
||||
tooltip:{backgroundColor:'#F0EFEB',borderWidth:0,
|
||||
textStyle:{color:'#28311F',fontFamily:'Inter',fontSize:12},padding:[10,14],
|
||||
formatter:p=>p.dataType==='node'
|
||||
?p.name+' — '+p.value+' contributors'
|
||||
:p.data.rawS+' ↔ '+p.data.rawT+' — '+p.data.w+' shared'},
|
||||
series:[{
|
||||
type:'graph',layout:'circular',
|
||||
circular:{rotateLabel:true},
|
||||
left:24,right:24,top:24,bottom:24,
|
||||
data:nodes.map((n,i)=>({
|
||||
name:n.name,value:n.value,
|
||||
symbolSize:3.5+Math.sqrt(n.value)*1.7,
|
||||
itemStyle:{color:ORGS[n.org][2],borderWidth:0},
|
||||
// only the giants get a label — 60 names would be soup
|
||||
label:{show:n.value>=26,position:'right',distance:4,
|
||||
color:'#ACAD79',fontFamily:'Inter',fontSize:8.5,fontWeight:600},
|
||||
})),
|
||||
links:links.map(l=>({
|
||||
source:l.source,target:l.target,
|
||||
rawS:nodes[l.source].name,rawT:nodes[l.target].name,w:l.w,
|
||||
lineStyle:{
|
||||
width:.4+l.w*.28,
|
||||
color:l.same?'#405333':'#334329',
|
||||
opacity:l.same?.5:.32,
|
||||
curveness:.3,
|
||||
},
|
||||
})),
|
||||
emphasis:{
|
||||
focus:'adjacency',
|
||||
lineStyle:{color:'#F2D17E',opacity:.95,width:1.6},
|
||||
label:{show:true,color:'#F2D17E'},
|
||||
},
|
||||
blur:{itemStyle:{opacity:.12},lineStyle:{opacity:.03}},
|
||||
}],
|
||||
};
|
||||
const play=()=>{g.clear();g.setOption(opt)};
|
||||
play();
|
||||
document.getElementById('ch').addEventListener('click',play);
|
||||
addEventListener('resize',()=>g.resize());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,112 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>青瓷蓝 — Circular Graph, Dense</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@6/dist/echarts.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--bg:#F7F2EB;--dark:#081F5C}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);font-family:'Inter',sans-serif;padding:40px;-webkit-font-smoothing:antialiased;
|
||||
display:flex;justify-content:center}
|
||||
.card{background:var(--dark);color:#EDEFF1;border-radius:24px;padding:34px 34px 24px;
|
||||
width:min(1100px,100%)}
|
||||
h2{font-weight:700;font-size:19px;letter-spacing:-.02em;margin-bottom:4px}
|
||||
.sub{font-size:12px;color:#809EC6;margin-bottom:10px}
|
||||
.src{font-size:9.5px;color:#3E5A8F;margin-top:12px;letter-spacing:.08em;font-weight:500}
|
||||
#ch{height:78vh;min-height:620px;cursor:pointer}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>Six hundred collaborations, one ring</h2>
|
||||
<div class="sub">60 repos · line = shared contributors · hover a repo to light up its world · click to replay · 蓝阶 8 档编码集群离核心的远近(亮 = 核心),hover 高亮走纸白微蓝。</div>
|
||||
<div id="ch"></div>
|
||||
<div class="src">CIRCULAR GRAPH · DENSE · SOURCE · GIT LOG · PORCELAIN</div>
|
||||
</div>
|
||||
<script>
|
||||
const INK='#081F5C',PAPER='#EDEFF1';
|
||||
const rnd=(i,k)=>((i*73856093)^(k*19349663))%1000/1000; // deterministic
|
||||
|
||||
// ── 60 repos in 5 org clusters; cluster = shade family ──
|
||||
const ORGS=[
|
||||
['platform',16,'#EDEFF1'], // paper — the core
|
||||
['product',14,'#BCC7D7'],
|
||||
['data',12,'#9EB3CD'],
|
||||
['infra',10,'#6C93C7'],
|
||||
['labs',8,'#3472C2'],
|
||||
];
|
||||
const nodes=[];
|
||||
ORGS.forEach(([org,count],oi)=>{
|
||||
for(let i=0;i<count;i++){
|
||||
// activity: a few giants per cluster, long tail of small repos
|
||||
const heavy=i<2?30+rnd(oi+1,i+2)*45:4+rnd(oi+3,i+7)*16;
|
||||
nodes.push({
|
||||
name:org+'/'+org.slice(0,2)+'-'+String(i+1).padStart(2,'0'),
|
||||
org:oi,value:Math.round(heavy),
|
||||
});
|
||||
}
|
||||
});
|
||||
// links: dense inside a cluster, sparser across; weight = shared contributors
|
||||
const links=[];
|
||||
const idx={};nodes.forEach((n,i)=>idx[n.name]=i);
|
||||
nodes.forEach((a,i)=>{
|
||||
nodes.forEach((b,j)=>{
|
||||
if(j<=i)return;
|
||||
const same=a.org===b.org;
|
||||
const p=same?.16:.045;
|
||||
if(rnd(i+1,j+2)<p){
|
||||
links.push({source:i,target:j,
|
||||
w:1+Math.round(rnd(i+2,j+3)*6),same});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const g=echarts.init(document.getElementById('ch'));
|
||||
const opt={
|
||||
animationDuration:1800,animationEasing:'quarticOut',
|
||||
tooltip:{backgroundColor:'#F7F2EB',borderWidth:0,
|
||||
textStyle:{color:'#081F5C',fontFamily:'Inter',fontSize:12},padding:[10,14],
|
||||
formatter:p=>p.dataType==='node'
|
||||
?p.name+' — '+p.value+' contributors'
|
||||
:p.data.rawS+' ↔ '+p.data.rawT+' — '+p.data.w+' shared'},
|
||||
series:[{
|
||||
type:'graph',layout:'circular',
|
||||
circular:{rotateLabel:true},
|
||||
left:24,right:24,top:24,bottom:24,
|
||||
data:nodes.map((n,i)=>({
|
||||
name:n.name,value:n.value,
|
||||
symbolSize:3.5+Math.sqrt(n.value)*1.7,
|
||||
itemStyle:{color:ORGS[n.org][2],borderWidth:0},
|
||||
// only the giants get a label — 60 names would be soup
|
||||
label:{show:n.value>=26,position:'right',distance:4,
|
||||
color:'#9EB3CD',fontFamily:'Inter',fontSize:8.5,fontWeight:600},
|
||||
})),
|
||||
links:links.map(l=>({
|
||||
source:l.source,target:l.target,
|
||||
rawS:nodes[l.source].name,rawT:nodes[l.target].name,w:l.w,
|
||||
lineStyle:{
|
||||
width:.4+l.w*.28,
|
||||
color:l.same?'#32517B':'#263E5E',
|
||||
opacity:l.same?.5:.32,
|
||||
curveness:.3,
|
||||
},
|
||||
})),
|
||||
emphasis:{
|
||||
focus:'adjacency',
|
||||
lineStyle:{color:'#EDEFF1',opacity:.95,width:1.6},
|
||||
label:{show:true,color:'#EDEFF1'},
|
||||
},
|
||||
blur:{itemStyle:{opacity:.12},lineStyle:{opacity:.03}},
|
||||
}],
|
||||
};
|
||||
const play=()=>{g.clear();g.setOption(opt)};
|
||||
play();
|
||||
document.getElementById('ch').addEventListener('click',play);
|
||||
addEventListener('resize',()=>g.resize());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,111 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>椰林绿 — Force Graph, Dense</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@6/dist/echarts.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--bg:#F0EFEB;--dark:#28311F}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);font-family:'Inter',sans-serif;padding:40px;-webkit-font-smoothing:antialiased;
|
||||
display:flex;justify-content:center}
|
||||
.card{background:var(--dark);color:#F0EFE9;border-radius:24px;padding:34px 34px 24px;
|
||||
width:min(1100px,100%)}
|
||||
h2{font-weight:700;font-size:19px;letter-spacing:-.02em;margin-bottom:4px}
|
||||
.sub{font-size:12px;color:#92A763;margin-bottom:10px}
|
||||
.src{font-size:9.5px;color:#5A6B47;margin-top:12px;letter-spacing:.08em;font-weight:500}
|
||||
#ch{height:78vh;min-height:620px;cursor:grab}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>A galaxy of one hundred eighty services</h2>
|
||||
<div class="sub">8 domains self-organize · node = call volume · drag anything, the galaxy fights back · click empty space to replay · 绿黄阶 8 档编码集群离核心的远近(亮 = 核心),hover 高亮走麦黄。</div>
|
||||
<div id="ch"></div>
|
||||
<div class="src">FORCE GRAPH · DENSE · SOURCE · SERVICE MESH · PALM</div>
|
||||
</div>
|
||||
<script>
|
||||
const INK='#28311F',PAPER='#F0EFE9';
|
||||
const rnd=(i,k)=>((i*73856093)^(k*19349663))%1000/1000; // deterministic
|
||||
|
||||
// ── 8 domain hubs, each with a swarm of services; hubs interconnect ──
|
||||
const DOMAINS=[
|
||||
['auth',26,'#F0EFE9'],['billing',24,'#DDDBC9'],['content',28,'#C8CAA8'],
|
||||
['search',20,'#AFB885'],['notify',22,'#92A763'],['media',20,'#7EA056'],
|
||||
['analytics',18,'#658D49'],['edge',14,'#4F7D3E'],
|
||||
];
|
||||
const nodes=[],links=[];
|
||||
DOMAINS.forEach(([dom,count,shade],di)=>{
|
||||
// the hub
|
||||
nodes.push({name:dom,value:60+Math.round(rnd(di+1,3)*50),hub:true,shade,dom:di});
|
||||
// satellites: long-tail sizes, a couple of mid-weights per domain
|
||||
for(let i=0;i<count;i++){
|
||||
const v=i<3?14+rnd(di+2,i+4)*18:2+rnd(di+5,i+9)*9;
|
||||
const name=dom+'-'+String(i+1).padStart(2,'0');
|
||||
nodes.push({name,value:Math.round(v),hub:false,shade,dom:di});
|
||||
// satellite -> its hub
|
||||
links.push({source:name,target:dom,
|
||||
lineStyle:{width:.5+v*.05,color:'#405333',opacity:.5}});
|
||||
// occasional satellite-to-satellite shortcut inside the domain
|
||||
if(i>1&&rnd(di+3,i+11)<.22){
|
||||
links.push({source:name,target:dom+'-'+String(1+Math.floor(rnd(di+4,i+13)*i)).padStart(2,'0'),
|
||||
lineStyle:{width:.5,color:'#334329',opacity:.4}});
|
||||
}
|
||||
}
|
||||
});
|
||||
// hub-to-hub backbone: every domain talks to 2-3 others
|
||||
const H=DOMAINS.length;
|
||||
for(let a=0;a<H;a++)for(let b=a+1;b<H;b++){
|
||||
if(rnd(a+7,b+9)<.42){
|
||||
links.push({source:DOMAINS[a][0],target:DOMAINS[b][0],
|
||||
lineStyle:{width:1.6+rnd(a+2,b+3)*2.2,color:'#405333',opacity:.65,curveness:.08}});
|
||||
}
|
||||
}
|
||||
// a few cross-domain satellite shortcuts — the messy real-world wires
|
||||
for(let k=0;k<26;k++){
|
||||
const a=nodes[1+Math.floor(rnd(k+1,17)*(nodes.length-1))];
|
||||
const b=nodes[1+Math.floor(rnd(k+3,23)*(nodes.length-1))];
|
||||
if(a.name!==b.name&&a.dom!==b.dom&&!a.hub&&!b.hub){
|
||||
links.push({source:a.name,target:b.name,
|
||||
lineStyle:{width:.5,color:'#2C3823',opacity:.38,curveness:.15}});
|
||||
}
|
||||
}
|
||||
|
||||
const g=echarts.init(document.getElementById('ch'));
|
||||
const opt={
|
||||
animationDuration:300,
|
||||
tooltip:{backgroundColor:'#F0EFEB',borderWidth:0,
|
||||
textStyle:{color:'#28311F',fontFamily:'Inter',fontSize:12},padding:[10,14],
|
||||
formatter:p=>p.dataType==='node'?p.name+' — '+p.value+'k calls/day':''},
|
||||
series:[{
|
||||
type:'graph',layout:'force',
|
||||
force:{repulsion:46,edgeLength:[10,42],gravity:.16,friction:.22,layoutAnimation:true},
|
||||
roam:true,draggable:true,
|
||||
left:6,right:6,top:6,bottom:6,
|
||||
data:nodes.map(n=>({
|
||||
name:n.name,value:n.value,
|
||||
symbolSize:n.hub?16+Math.sqrt(n.value)*1.6:2.5+Math.sqrt(n.value)*1.5,
|
||||
itemStyle:{color:n.shade,borderWidth:n.hub?2:0,borderColor:INK},
|
||||
label:{show:n.hub,position:'inside',
|
||||
color:INK,fontFamily:'Inter',fontSize:9.5,fontWeight:800},
|
||||
})),
|
||||
links,
|
||||
emphasis:{
|
||||
focus:'adjacency',
|
||||
lineStyle:{color:'#F2D17E',opacity:.95,width:1.4},
|
||||
label:{show:true,color:PAPER,position:'right'},
|
||||
},
|
||||
blur:{itemStyle:{opacity:.1},lineStyle:{opacity:.03}},
|
||||
}],
|
||||
};
|
||||
const play=()=>{g.clear();g.setOption(opt)};
|
||||
play();
|
||||
// click on empty canvas replays; clicks on nodes are for dragging
|
||||
g.getZr().on('click',e=>{if(!e.target)play()});
|
||||
addEventListener('resize',()=>g.resize());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,111 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>青瓷蓝 — Force Graph, Dense</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@6/dist/echarts.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--bg:#F7F2EB;--dark:#081F5C}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);font-family:'Inter',sans-serif;padding:40px;-webkit-font-smoothing:antialiased;
|
||||
display:flex;justify-content:center}
|
||||
.card{background:var(--dark);color:#EDEFF1;border-radius:24px;padding:34px 34px 24px;
|
||||
width:min(1100px,100%)}
|
||||
h2{font-weight:700;font-size:19px;letter-spacing:-.02em;margin-bottom:4px}
|
||||
.sub{font-size:12px;color:#809EC6;margin-bottom:10px}
|
||||
.src{font-size:9.5px;color:#3E5A8F;margin-top:12px;letter-spacing:.08em;font-weight:500}
|
||||
#ch{height:78vh;min-height:620px;cursor:grab}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>A galaxy of one hundred eighty services</h2>
|
||||
<div class="sub">8 domains self-organize · node = call volume · drag anything, the galaxy fights back · click empty space to replay · 蓝阶 8 档编码集群离核心的远近(亮 = 核心),hover 高亮走纸白微蓝。</div>
|
||||
<div id="ch"></div>
|
||||
<div class="src">FORCE GRAPH · DENSE · SOURCE · SERVICE MESH · PORCELAIN</div>
|
||||
</div>
|
||||
<script>
|
||||
const INK='#081F5C',PAPER='#EDEFF1';
|
||||
const rnd=(i,k)=>((i*73856093)^(k*19349663))%1000/1000; // deterministic
|
||||
|
||||
// ── 8 domain hubs, each with a swarm of services; hubs interconnect ──
|
||||
const DOMAINS=[
|
||||
['auth',26,'#EDEFF1'],['billing',24,'#D5DBE2'],['content',28,'#BCC7D7'],
|
||||
['search',20,'#9EB3CD'],['notify',22,'#809EC6'],['media',20,'#6C93C7'],
|
||||
['analytics',18,'#4D82C6'],['edge',14,'#3472C2'],
|
||||
];
|
||||
const nodes=[],links=[];
|
||||
DOMAINS.forEach(([dom,count,shade],di)=>{
|
||||
// the hub
|
||||
nodes.push({name:dom,value:60+Math.round(rnd(di+1,3)*50),hub:true,shade,dom:di});
|
||||
// satellites: long-tail sizes, a couple of mid-weights per domain
|
||||
for(let i=0;i<count;i++){
|
||||
const v=i<3?14+rnd(di+2,i+4)*18:2+rnd(di+5,i+9)*9;
|
||||
const name=dom+'-'+String(i+1).padStart(2,'0');
|
||||
nodes.push({name,value:Math.round(v),hub:false,shade,dom:di});
|
||||
// satellite -> its hub
|
||||
links.push({source:name,target:dom,
|
||||
lineStyle:{width:.5+v*.05,color:'#32517B',opacity:.5}});
|
||||
// occasional satellite-to-satellite shortcut inside the domain
|
||||
if(i>1&&rnd(di+3,i+11)<.22){
|
||||
links.push({source:name,target:dom+'-'+String(1+Math.floor(rnd(di+4,i+13)*i)).padStart(2,'0'),
|
||||
lineStyle:{width:.5,color:'#263E5E',opacity:.4}});
|
||||
}
|
||||
}
|
||||
});
|
||||
// hub-to-hub backbone: every domain talks to 2-3 others
|
||||
const H=DOMAINS.length;
|
||||
for(let a=0;a<H;a++)for(let b=a+1;b<H;b++){
|
||||
if(rnd(a+7,b+9)<.42){
|
||||
links.push({source:DOMAINS[a][0],target:DOMAINS[b][0],
|
||||
lineStyle:{width:1.6+rnd(a+2,b+3)*2.2,color:'#32517B',opacity:.65,curveness:.08}});
|
||||
}
|
||||
}
|
||||
// a few cross-domain satellite shortcuts — the messy real-world wires
|
||||
for(let k=0;k<26;k++){
|
||||
const a=nodes[1+Math.floor(rnd(k+1,17)*(nodes.length-1))];
|
||||
const b=nodes[1+Math.floor(rnd(k+3,23)*(nodes.length-1))];
|
||||
if(a.name!==b.name&&a.dom!==b.dom&&!a.hub&&!b.hub){
|
||||
links.push({source:a.name,target:b.name,
|
||||
lineStyle:{width:.5,color:'#213650',opacity:.38,curveness:.15}});
|
||||
}
|
||||
}
|
||||
|
||||
const g=echarts.init(document.getElementById('ch'));
|
||||
const opt={
|
||||
animationDuration:300,
|
||||
tooltip:{backgroundColor:'#F7F2EB',borderWidth:0,
|
||||
textStyle:{color:'#081F5C',fontFamily:'Inter',fontSize:12},padding:[10,14],
|
||||
formatter:p=>p.dataType==='node'?p.name+' — '+p.value+'k calls/day':''},
|
||||
series:[{
|
||||
type:'graph',layout:'force',
|
||||
force:{repulsion:46,edgeLength:[10,42],gravity:.16,friction:.22,layoutAnimation:true},
|
||||
roam:true,draggable:true,
|
||||
left:6,right:6,top:6,bottom:6,
|
||||
data:nodes.map(n=>({
|
||||
name:n.name,value:n.value,
|
||||
symbolSize:n.hub?16+Math.sqrt(n.value)*1.6:2.5+Math.sqrt(n.value)*1.5,
|
||||
itemStyle:{color:n.shade,borderWidth:n.hub?2:0,borderColor:INK},
|
||||
label:{show:n.hub,position:'inside',
|
||||
color:INK,fontFamily:'Inter',fontSize:9.5,fontWeight:800},
|
||||
})),
|
||||
links,
|
||||
emphasis:{
|
||||
focus:'adjacency',
|
||||
lineStyle:{color:'#EDEFF1',opacity:.95,width:1.4},
|
||||
label:{show:true,color:PAPER,position:'right'},
|
||||
},
|
||||
blur:{itemStyle:{opacity:.1},lineStyle:{opacity:.03}},
|
||||
}],
|
||||
};
|
||||
const play=()=>{g.clear();g.setOption(opt)};
|
||||
play();
|
||||
// click on empty canvas replays; clicks on nodes are for dragging
|
||||
g.getZr().on('click',e=>{if(!e.target)play()});
|
||||
addEventListener('resize',()=>g.resize());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,164 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>椰林绿 — Thread Triptych, Interactive</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--bg:#F0EFEB;--dark:#28311F;--paper:#F0EFEB}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);font-family:'Inter',sans-serif;padding:40px;-webkit-font-smoothing:antialiased;
|
||||
display:flex;justify-content:center}
|
||||
.card{background:var(--dark);color:var(--paper);border-radius:24px;padding:34px 34px 24px;
|
||||
width:min(1200px,100%)}
|
||||
h2{font-weight:700;font-size:19px;letter-spacing:-.02em;margin-bottom:4px}
|
||||
.sub{font-size:12px;color:#92A763;margin-bottom:6px}
|
||||
.src{font-size:9.5px;color:#5A6B47;margin-top:12px;letter-spacing:.08em;font-weight:500}
|
||||
#ch{width:100%;height:74vh;min-height:560px;display:block}
|
||||
#status{height:34px;display:flex;align-items:center;gap:10px;
|
||||
font-size:12px;color:#92A763;border-top:1px solid #38452C;margin-top:6px;padding-top:8px}
|
||||
#status b{color:var(--paper);font-weight:700}
|
||||
#status .pin{font-size:9px;letter-spacing:.1em;color:#5A6B47;font-weight:700}
|
||||
svg text{font-family:'Inter',sans-serif;user-select:none}
|
||||
|
||||
/* thread states */
|
||||
.thread{transition:opacity .18s ease,stroke-width .18s ease}
|
||||
.thread.hot{opacity:.95 !important}
|
||||
svg.focused .thread:not(.hot){opacity:.035 !important}
|
||||
.nodelab{cursor:pointer;transition:opacity .18s ease}
|
||||
svg.focused .nodelab:not(.hot){opacity:.25}
|
||||
.hit{cursor:pointer}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>Where the data actually goes — now answerable</h2>
|
||||
<div class="sub">source → processor → destination · one thread per data path · hover to trace a route · hover any label to pull its whole bundle · click to pin, click dark space to release</div>
|
||||
<svg id="ch" viewBox="0 0 1140 640" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div id="status"><span id="stext">hover a thread or a label</span><span class="pin" id="spin"></span></div>
|
||||
<div class="src">THREAD TRIPTYCH · INTERACTIVE · DATA MAP · PALM</div>
|
||||
</div>
|
||||
<script>
|
||||
const PAPER='#F0EFE9',NS='http://www.w3.org/2000/svg';
|
||||
const el=(p,t,a)=>{const n=document.createElementNS(NS,t);for(const k in a)n.setAttribute(k,a[k]);p.appendChild(n);return n};
|
||||
const txt=(p,a,s)=>{const n=el(p,'text',a);n.textContent=s;return n};
|
||||
const rnd=(i,k)=>Math.abs(((i*73856093)^(k*19349663))%1000)/1000;
|
||||
|
||||
// ── the pipeline ──
|
||||
const SRC=['web-app','ios-app','android','desktop','cli','public-api','webhooks','forms','email-in','chat-widget',
|
||||
'billing-ev','auth-ev','search-log','audit-ev','crash-rep','perf-beacon','cdn-log','edge-log','job-ev','sync-ev',
|
||||
'import','export-req','admin-ui','partner-api','iot-ping','backfill'];
|
||||
const MIDV=['parse','validate','dedupe','enrich','geoip','sessionize','pii-mask','score','sample','route',
|
||||
'batch','window','join','flatten','filter','hash','encrypt','compact','index','tag'];
|
||||
const MID=MIDV.flatMap(v=>[v+'-a',v+'-b']); // 40 processors
|
||||
const DST=['WAREHOUSE','ANALYTICS','CRM','ADS','BILLING','SEARCH','ML PIPE','ARCHIVE','AUDIT','EXPORTS'];
|
||||
|
||||
// routes: each source emits 2-3 full paths; volume is long-tailed
|
||||
const routes=[];
|
||||
SRC.forEach((s,i)=>{
|
||||
const n=2+Math.floor(rnd(i+1,5)*2);
|
||||
for(let k=0;k<n;k++){
|
||||
const mi=Math.floor(rnd(i*3+k+2,13)*MID.length);
|
||||
const di=Math.min(9,Math.floor(rnd(i*5+k+4,19)**2*11)); // skew: low dsts hoard
|
||||
const v=Math.round(1+rnd(i*7+k+6,29)**2*30); // 1..31, long tail
|
||||
routes.push({s:i,m:mi,d:di,v});
|
||||
}
|
||||
});
|
||||
|
||||
// ── geometry ──
|
||||
const X1=180,X2=560,X3=930;
|
||||
const sy=i=>36+i*22.6, my=i=>30+i*14.6, dy=i=>60+i*57;
|
||||
const path=r=>`M${X1+6} ${sy(r.s)} C${(X1+X2)/2} ${sy(r.s)} ${(X1+X2)/2} ${my(r.m)} ${X2} ${my(r.m)}`
|
||||
+` C${(X2+X3)/2} ${my(r.m)} ${(X2+X3)/2} ${dy(r.d)} ${X3-6} ${dy(r.d)}`;
|
||||
|
||||
const svg=document.getElementById('ch');
|
||||
const stext=document.getElementById('stext'),spin=document.getElementById('spin');
|
||||
const fmt=v=>v+'k ev/day';
|
||||
let pinned=null;
|
||||
|
||||
// column headers
|
||||
[[X1,'① SOURCE'],[X2,'② PROCESSOR'],[X3,'③ DESTINATION']].forEach(([x,t])=>
|
||||
txt(svg,{x,y:14,'font-size':9,'font-weight':800,fill:PAPER,'letter-spacing':'.14em','text-anchor':'middle'},t));
|
||||
|
||||
// threads (drawn first, under labels)
|
||||
const gThreads=el(svg,'g',{});
|
||||
routes.forEach((r,ri)=>{
|
||||
r.el=el(gThreads,'path',{d:path(r),fill:'none',stroke:PAPER,
|
||||
'stroke-width':Math.max(.6,r.v*.14),class:'thread',
|
||||
opacity:.06+Math.min(.2,r.v*.012),'stroke-linecap':'round'});
|
||||
// invisible fat twin: the hover/click target
|
||||
r.hit=el(gThreads,'path',{d:path(r),fill:'none',stroke:'#000','stroke-opacity':0,
|
||||
'stroke-width':9,class:'hit'});
|
||||
r.hit.dataset.route=ri;
|
||||
});
|
||||
|
||||
// node labels + ticks; each carries hit metadata
|
||||
const mkNode=(x,y,label,anchor,cls,meta,size,fill)=>{
|
||||
const g=el(svg,'g',{class:'nodelab'});
|
||||
g.dataset.kind=meta.kind;g.dataset.idx=meta.idx;
|
||||
el(g,'rect',{x:x-3,y:y-1.6,width:6,height:3.2,fill:PAPER,opacity:.85});
|
||||
txt(g,{x:anchor==='end'?x-9:x+9,y:y+2.6,'font-size':size,'font-weight':600,
|
||||
fill,'text-anchor':anchor},label);
|
||||
// generous invisible hit zone
|
||||
el(g,'rect',{x:anchor==='end'?x-92:x-6,y:y-6.5,width:98,height:13,fill:'#000','fill-opacity':0,class:'hit'});
|
||||
return g;
|
||||
};
|
||||
SRC.forEach((s,i)=>mkNode(X1,sy(i),s,'end','',{kind:'s',idx:i},7.5,'#ACAD79'));
|
||||
MID.forEach((m,i)=>mkNode(X2,my(i),m,'start','',{kind:'m',idx:i},5.4,'#4F7D3E'));
|
||||
DST.forEach((d,i)=>{
|
||||
const g=el(svg,'g',{class:'nodelab'});
|
||||
g.dataset.kind='d';g.dataset.idx=i;
|
||||
el(g,'circle',{cx:X3,cy:dy(i),r:3.4,fill:PAPER});
|
||||
txt(g,{x:X3+12,y:dy(i)+3,'font-size':8.5,'font-weight':700,fill:'#ACAD79','letter-spacing':'.08em'},d);
|
||||
el(g,'rect',{x:X3-8,y:dy(i)-10,width:120,height:20,fill:'#000','fill-opacity':0,class:'hit'});
|
||||
});
|
||||
|
||||
// ── highlight machinery ──
|
||||
const nodeLabs=[...svg.querySelectorAll('.nodelab')];
|
||||
const clear=()=>{
|
||||
svg.classList.remove('focused');
|
||||
routes.forEach(r=>r.el.classList.remove('hot'));
|
||||
nodeLabs.forEach(n=>n.classList.remove('hot'));
|
||||
stext.textContent='hover a thread or a label';spin.textContent='';
|
||||
};
|
||||
const focusRoutes=(rs,label)=>{
|
||||
svg.classList.add('focused');
|
||||
routes.forEach(r=>r.el.classList.toggle('hot',rs.includes(r)));
|
||||
nodeLabs.forEach(n=>{
|
||||
const k=n.dataset.kind,ix=+n.dataset.idx;
|
||||
n.classList.toggle('hot',rs.some(r=>(k==='s'&&r.s===ix)||(k==='m'&&r.m===ix)||(k==='d'&&r.d===ix)));
|
||||
});
|
||||
stext.innerHTML=label;
|
||||
};
|
||||
const routeLabel=r=>`<b>${SRC[r.s]}</b> → ${MID[r.m]} → <b>${DST[r.d]}</b> · ${fmt(r.v)}`;
|
||||
const nodeSel=(kind,idx)=>{
|
||||
const rs=routes.filter(r=>(kind==='s'&&r.s===idx)||(kind==='m'&&r.m===idx)||(kind==='d'&&r.d===idx));
|
||||
const total=rs.reduce((a,r)=>a+r.v,0);
|
||||
const name=kind==='s'?SRC[idx]:kind==='m'?MID[idx]:DST[idx];
|
||||
return [rs,`<b>${name}</b> · ${rs.length} path${rs.length>1?'s':''} · ${fmt(total)} total`];
|
||||
};
|
||||
|
||||
const apply=t=>{
|
||||
const hit=t.closest?.('.hit');
|
||||
if(!hit)return null;
|
||||
const rg=hit.dataset.route,ng=hit.closest('.nodelab');
|
||||
if(rg!=null){const r=routes[+rg];return()=>focusRoutes([r],routeLabel(r))}
|
||||
if(ng){const [rs,lab]=nodeSel(ng.dataset.kind,+ng.dataset.idx);return()=>focusRoutes(rs,lab)}
|
||||
return null;
|
||||
};
|
||||
svg.addEventListener('mousemove',e=>{
|
||||
if(pinned)return;
|
||||
const f=apply(e.target);
|
||||
f?f():clear();
|
||||
});
|
||||
svg.addEventListener('mouseleave',()=>{if(!pinned)clear()});
|
||||
svg.addEventListener('click',e=>{
|
||||
const f=apply(e.target);
|
||||
if(f){pinned=f;f();spin.textContent='PINNED · CLICK DARK SPACE TO RELEASE'}
|
||||
else{pinned=null;clear()}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,164 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>青瓷蓝 — Thread Triptych, Interactive</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--bg:#F7F2EB;--dark:#081F5C;--paper:#F7F2EB}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);font-family:'Inter',sans-serif;padding:40px;-webkit-font-smoothing:antialiased;
|
||||
display:flex;justify-content:center}
|
||||
.card{background:var(--dark);color:var(--paper);border-radius:24px;padding:34px 34px 24px;
|
||||
width:min(1200px,100%)}
|
||||
h2{font-weight:700;font-size:19px;letter-spacing:-.02em;margin-bottom:4px}
|
||||
.sub{font-size:12px;color:#809EC6;margin-bottom:6px}
|
||||
.src{font-size:9.5px;color:#3E5A8F;margin-top:12px;letter-spacing:.08em;font-weight:500}
|
||||
#ch{width:100%;height:74vh;min-height:560px;display:block}
|
||||
#status{height:34px;display:flex;align-items:center;gap:10px;
|
||||
font-size:12px;color:#809EC6;border-top:1px solid #1B3159;margin-top:6px;padding-top:8px}
|
||||
#status b{color:var(--paper);font-weight:700}
|
||||
#status .pin{font-size:9px;letter-spacing:.1em;color:#3E5A8F;font-weight:700}
|
||||
svg text{font-family:'Inter',sans-serif;user-select:none}
|
||||
|
||||
/* thread states */
|
||||
.thread{transition:opacity .18s ease,stroke-width .18s ease}
|
||||
.thread.hot{opacity:.95 !important}
|
||||
svg.focused .thread:not(.hot){opacity:.035 !important}
|
||||
.nodelab{cursor:pointer;transition:opacity .18s ease}
|
||||
svg.focused .nodelab:not(.hot){opacity:.25}
|
||||
.hit{cursor:pointer}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>Where the data actually goes — now answerable</h2>
|
||||
<div class="sub">source → processor → destination · one thread per data path · hover to trace a route · hover any label to pull its whole bundle · click to pin, click dark space to release</div>
|
||||
<svg id="ch" viewBox="0 0 1140 640" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div id="status"><span id="stext">hover a thread or a label</span><span class="pin" id="spin"></span></div>
|
||||
<div class="src">THREAD TRIPTYCH · INTERACTIVE · DATA MAP · PORCELAIN</div>
|
||||
</div>
|
||||
<script>
|
||||
const PAPER='#EDEFF1',NS='http://www.w3.org/2000/svg';
|
||||
const el=(p,t,a)=>{const n=document.createElementNS(NS,t);for(const k in a)n.setAttribute(k,a[k]);p.appendChild(n);return n};
|
||||
const txt=(p,a,s)=>{const n=el(p,'text',a);n.textContent=s;return n};
|
||||
const rnd=(i,k)=>Math.abs(((i*73856093)^(k*19349663))%1000)/1000;
|
||||
|
||||
// ── the pipeline ──
|
||||
const SRC=['web-app','ios-app','android','desktop','cli','public-api','webhooks','forms','email-in','chat-widget',
|
||||
'billing-ev','auth-ev','search-log','audit-ev','crash-rep','perf-beacon','cdn-log','edge-log','job-ev','sync-ev',
|
||||
'import','export-req','admin-ui','partner-api','iot-ping','backfill'];
|
||||
const MIDV=['parse','validate','dedupe','enrich','geoip','sessionize','pii-mask','score','sample','route',
|
||||
'batch','window','join','flatten','filter','hash','encrypt','compact','index','tag'];
|
||||
const MID=MIDV.flatMap(v=>[v+'-a',v+'-b']); // 40 processors
|
||||
const DST=['WAREHOUSE','ANALYTICS','CRM','ADS','BILLING','SEARCH','ML PIPE','ARCHIVE','AUDIT','EXPORTS'];
|
||||
|
||||
// routes: each source emits 2-3 full paths; volume is long-tailed
|
||||
const routes=[];
|
||||
SRC.forEach((s,i)=>{
|
||||
const n=2+Math.floor(rnd(i+1,5)*2);
|
||||
for(let k=0;k<n;k++){
|
||||
const mi=Math.floor(rnd(i*3+k+2,13)*MID.length);
|
||||
const di=Math.min(9,Math.floor(rnd(i*5+k+4,19)**2*11)); // skew: low dsts hoard
|
||||
const v=Math.round(1+rnd(i*7+k+6,29)**2*30); // 1..31, long tail
|
||||
routes.push({s:i,m:mi,d:di,v});
|
||||
}
|
||||
});
|
||||
|
||||
// ── geometry ──
|
||||
const X1=180,X2=560,X3=930;
|
||||
const sy=i=>36+i*22.6, my=i=>30+i*14.6, dy=i=>60+i*57;
|
||||
const path=r=>`M${X1+6} ${sy(r.s)} C${(X1+X2)/2} ${sy(r.s)} ${(X1+X2)/2} ${my(r.m)} ${X2} ${my(r.m)}`
|
||||
+` C${(X2+X3)/2} ${my(r.m)} ${(X2+X3)/2} ${dy(r.d)} ${X3-6} ${dy(r.d)}`;
|
||||
|
||||
const svg=document.getElementById('ch');
|
||||
const stext=document.getElementById('stext'),spin=document.getElementById('spin');
|
||||
const fmt=v=>v+'k ev/day';
|
||||
let pinned=null;
|
||||
|
||||
// column headers
|
||||
[[X1,'① SOURCE'],[X2,'② PROCESSOR'],[X3,'③ DESTINATION']].forEach(([x,t])=>
|
||||
txt(svg,{x,y:14,'font-size':9,'font-weight':800,fill:PAPER,'letter-spacing':'.14em','text-anchor':'middle'},t));
|
||||
|
||||
// threads (drawn first, under labels)
|
||||
const gThreads=el(svg,'g',{});
|
||||
routes.forEach((r,ri)=>{
|
||||
r.el=el(gThreads,'path',{d:path(r),fill:'none',stroke:PAPER,
|
||||
'stroke-width':Math.max(.6,r.v*.14),class:'thread',
|
||||
opacity:.06+Math.min(.2,r.v*.012),'stroke-linecap':'round'});
|
||||
// invisible fat twin: the hover/click target
|
||||
r.hit=el(gThreads,'path',{d:path(r),fill:'none',stroke:'#000','stroke-opacity':0,
|
||||
'stroke-width':9,class:'hit'});
|
||||
r.hit.dataset.route=ri;
|
||||
});
|
||||
|
||||
// node labels + ticks; each carries hit metadata
|
||||
const mkNode=(x,y,label,anchor,cls,meta,size,fill)=>{
|
||||
const g=el(svg,'g',{class:'nodelab'});
|
||||
g.dataset.kind=meta.kind;g.dataset.idx=meta.idx;
|
||||
el(g,'rect',{x:x-3,y:y-1.6,width:6,height:3.2,fill:PAPER,opacity:.85});
|
||||
txt(g,{x:anchor==='end'?x-9:x+9,y:y+2.6,'font-size':size,'font-weight':600,
|
||||
fill,'text-anchor':anchor},label);
|
||||
// generous invisible hit zone
|
||||
el(g,'rect',{x:anchor==='end'?x-92:x-6,y:y-6.5,width:98,height:13,fill:'#000','fill-opacity':0,class:'hit'});
|
||||
return g;
|
||||
};
|
||||
SRC.forEach((s,i)=>mkNode(X1,sy(i),s,'end','',{kind:'s',idx:i},7.5,'#9EB3CD'));
|
||||
MID.forEach((m,i)=>mkNode(X2,my(i),m,'start','',{kind:'m',idx:i},5.4,'#3472C2'));
|
||||
DST.forEach((d,i)=>{
|
||||
const g=el(svg,'g',{class:'nodelab'});
|
||||
g.dataset.kind='d';g.dataset.idx=i;
|
||||
el(g,'circle',{cx:X3,cy:dy(i),r:3.4,fill:PAPER});
|
||||
txt(g,{x:X3+12,y:dy(i)+3,'font-size':8.5,'font-weight':700,fill:'#9EB3CD','letter-spacing':'.08em'},d);
|
||||
el(g,'rect',{x:X3-8,y:dy(i)-10,width:120,height:20,fill:'#000','fill-opacity':0,class:'hit'});
|
||||
});
|
||||
|
||||
// ── highlight machinery ──
|
||||
const nodeLabs=[...svg.querySelectorAll('.nodelab')];
|
||||
const clear=()=>{
|
||||
svg.classList.remove('focused');
|
||||
routes.forEach(r=>r.el.classList.remove('hot'));
|
||||
nodeLabs.forEach(n=>n.classList.remove('hot'));
|
||||
stext.textContent='hover a thread or a label';spin.textContent='';
|
||||
};
|
||||
const focusRoutes=(rs,label)=>{
|
||||
svg.classList.add('focused');
|
||||
routes.forEach(r=>r.el.classList.toggle('hot',rs.includes(r)));
|
||||
nodeLabs.forEach(n=>{
|
||||
const k=n.dataset.kind,ix=+n.dataset.idx;
|
||||
n.classList.toggle('hot',rs.some(r=>(k==='s'&&r.s===ix)||(k==='m'&&r.m===ix)||(k==='d'&&r.d===ix)));
|
||||
});
|
||||
stext.innerHTML=label;
|
||||
};
|
||||
const routeLabel=r=>`<b>${SRC[r.s]}</b> → ${MID[r.m]} → <b>${DST[r.d]}</b> · ${fmt(r.v)}`;
|
||||
const nodeSel=(kind,idx)=>{
|
||||
const rs=routes.filter(r=>(kind==='s'&&r.s===idx)||(kind==='m'&&r.m===idx)||(kind==='d'&&r.d===idx));
|
||||
const total=rs.reduce((a,r)=>a+r.v,0);
|
||||
const name=kind==='s'?SRC[idx]:kind==='m'?MID[idx]:DST[idx];
|
||||
return [rs,`<b>${name}</b> · ${rs.length} path${rs.length>1?'s':''} · ${fmt(total)} total`];
|
||||
};
|
||||
|
||||
const apply=t=>{
|
||||
const hit=t.closest?.('.hit');
|
||||
if(!hit)return null;
|
||||
const rg=hit.dataset.route,ng=hit.closest('.nodelab');
|
||||
if(rg!=null){const r=routes[+rg];return()=>focusRoutes([r],routeLabel(r))}
|
||||
if(ng){const [rs,lab]=nodeSel(ng.dataset.kind,+ng.dataset.idx);return()=>focusRoutes(rs,lab)}
|
||||
return null;
|
||||
};
|
||||
svg.addEventListener('mousemove',e=>{
|
||||
if(pinned)return;
|
||||
const f=apply(e.target);
|
||||
f?f():clear();
|
||||
});
|
||||
svg.addEventListener('mouseleave',()=>{if(!pinned)clear()});
|
||||
svg.addEventListener('click',e=>{
|
||||
const f=apply(e.target);
|
||||
if(f){pinned=f;f();spin.textContent='PINNED · CLICK DARK SPACE TO RELEASE'}
|
||||
else{pinned=null;clear()}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,180 @@
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Lieflat Charts · Maps M1–M2 × PALM</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@6/dist/echarts.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--bg:#F0EFEB;--dark:#43593B;--ink:#58402E;--muted:rgba(88,64,46,.60);--faint:rgba(88,64,46,.32);--grid:rgba(88,64,46,.16)}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);font-family:'Inter',sans-serif;color:var(--ink);padding:40px;-webkit-font-smoothing:antialiased}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:22px;max-width:1400px;margin:0 auto}
|
||||
.card{background:var(--bg);border-radius:24px;padding:28px 28px 20px}
|
||||
.card.wide{grid-column:1/-1}
|
||||
h2{font-weight:700;font-size:16.5px;letter-spacing:-.02em;margin-bottom:3px}
|
||||
.sub{font-size:11.5px;color:var(--muted);margin-bottom:14px}
|
||||
.src{font-size:9.5px;color:var(--faint);margin-top:10px;letter-spacing:.08em;font-weight:500}
|
||||
.badge{display:inline-block;font-size:9px;font-weight:700;letter-spacing:.08em;
|
||||
padding:3px 10px;border-radius:99px;margin-bottom:10px;border:1px dashed #ACAD79;color:#929960}
|
||||
svg{width:100%;max-height:330px;display:block;margin:0 auto}
|
||||
svg text{font-family:'Inter',sans-serif}
|
||||
.pop{transform-box:fill-box;transform-origin:center;animation:pop .5s cubic-bezier(.2,.7,.3,1.3) both}
|
||||
@keyframes pop{from{transform:scale(0)}to{transform:none}}
|
||||
.fade{animation:fade .9s ease both}
|
||||
@keyframes fade{from{opacity:0}}
|
||||
.draw{stroke-dasharray:1;stroke-dashoffset:1;animation:draw 1s cubic-bezier(.4,0,.2,1) both}
|
||||
@keyframes draw{to{stroke-dashoffset:0}}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.pop,.fade{animation:none}
|
||||
.draw{animation:none;stroke-dasharray:none;stroke-dashoffset:0}
|
||||
}
|
||||
.pagehead{max-width:1400px;margin:0 auto 26px;padding:0 4px}
|
||||
.pagehead h1{font-size:22px;letter-spacing:-.02em;font-weight:800}
|
||||
.pagehead p{font-size:12px;color:#929960;margin-top:5px;line-height:1.7}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="pagehead">
|
||||
<h1>Maps M1–M2 × PALM</h1>
|
||||
<p>椰林绿地图换肤样张。明度表示区域数值;仅在用户明确要求地图时使用。</p>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<!-- M1 · US Choropleth -->
|
||||
<div class="card">
|
||||
<span class="badge">MAPS · US CHOROPLETH</span>
|
||||
<h2>Sign-ups across the states</h2>
|
||||
<div class="sub">real outlines · Alaska and Hawaii tucked lower-left · shade = sign-ups, five steps · needs the network for map data</div>
|
||||
<div class="ch" id="mapus" style="height:340px"></div>
|
||||
<div class="src">CHOROPLETH · PALM · GROWTH GEO</div>
|
||||
</div>
|
||||
|
||||
<!-- M2 · World Choropleth -->
|
||||
<div class="card wide">
|
||||
<span class="badge">MAPS · WORLD CHOROPLETH</span>
|
||||
<h2>Where the users are</h2>
|
||||
<div class="sub">real outlines · shade = monthly active accounts · gray = markets we haven't opened</div>
|
||||
<div class="ch" id="mapworld" style="height:400px"></div>
|
||||
<div class="src">CHOROPLETH · PALM · GLOBAL OPS</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const INK='#43593B',PAPER='#F0EFEB',MUTED='rgba(88,64,46,.60)',GRID='rgba(88,64,46,.16)';
|
||||
const obsReveal=(id,fn)=>{
|
||||
const n=document.getElementById(id);
|
||||
const go=()=>fn(n);
|
||||
const io=new IntersectionObserver(es=>{if(es[0].isIntersecting){go();io.disconnect()}},{threshold:.3});
|
||||
io.observe(n);
|
||||
n.style.cursor='pointer';
|
||||
n.addEventListener('click',go);
|
||||
};
|
||||
|
||||
// ════ M1 · US choropleth ════
|
||||
// 真轮廓地图:ECharts registerMap + GeoJSON(ECharts 官方示例数据,jsdelivr)。
|
||||
// 阿拉斯加/夏威夷/波多黎各用 registerMap 第三参 specialAreas 收进左下角
|
||||
// (官方 USA 示例同款,零额外依赖;d3 albersUsa 投影方案在部分环境渲染空白,弃用)。
|
||||
// Mono 化三件事:piecewise 五档灰阶 visualMap(明度即数据)、
|
||||
// 纸色描边当州界发丝、无数据州留 rgba(88,64,46,.10)(沉默可见)。
|
||||
// 面积失真的坦白:州的大小是地理,不是数值(蒙大拿大 ≠ 蒙大拿多)。
|
||||
// ⚠️ 如果换成中国地图:数据源必须含台湾省 + 南海诸岛 + 九段线
|
||||
// (用 DataV GeoAtlas 100000_full 或其完整镜像),发布前核对当期审图号。
|
||||
const MAP_LEGEND={
|
||||
itemWidth:11,itemHeight:11,itemSymbol:'rect',
|
||||
textStyle:{fontFamily:'Inter',fontSize:9,color:'#929960'},
|
||||
inRange:{color:['#F2D17E','#ACAD79','#929960','#77835A','#43593B']},
|
||||
};
|
||||
(()=>{
|
||||
const D={'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};
|
||||
fetch('https://cdn.jsdelivr.net/gh/apache/echarts-examples@gh-pages/public/data/asset/geo/USA.json')
|
||||
.then(r=>r.json()).then(geo=>{
|
||||
echarts.registerMap('mono-usa',geo,{
|
||||
Alaska:{left:-131,top:25,width:15},
|
||||
Hawaii:{left:-110,top:28,width:5},
|
||||
'Puerto Rico':{left:-76,top:26,width:2},
|
||||
});
|
||||
const opt={
|
||||
animationDuration:900,animationEasing:'quarticOut',
|
||||
tooltip:{backgroundColor:INK,borderWidth:0,padding:[10,14],
|
||||
textStyle:{color:PAPER,fontFamily:'Inter',fontSize:12},
|
||||
formatter:p=>isNaN(p.value)?p.name+' — no data':p.name+' — '+p.value+'k sign-ups'},
|
||||
visualMap:{type:'piecewise',bottom:6,left:'center',orient:'horizontal',
|
||||
pieces:[{max:9,label:'≤9k'},{min:10,max:20,label:'10–20k'},{min:21,max:38,label:'21–38k'},
|
||||
{min:39,max:64,label:'39–64k'},{min:65,label:'65k+'}],
|
||||
...MAP_LEGEND},
|
||||
series:[{type:'map',map:'mono-usa',top:10,bottom:48,
|
||||
itemStyle:{areaColor:'rgba(88,64,46,.10)',borderColor:PAPER,borderWidth:1.8},
|
||||
emphasis:{label:{show:true,fontFamily:'Inter',fontSize:10,fontWeight:800,color:INK,
|
||||
textBorderColor:PAPER,textBorderWidth:3},
|
||||
itemStyle:{areaColor:null,borderColor:INK,borderWidth:1.2}},
|
||||
select:{disabled:true},
|
||||
data:Object.entries(D).map(([n,v])=>({name:n,value:v,
|
||||
label:n==='California'?{show:true,formatter:'CA 96k',fontFamily:'Inter',fontSize:9,
|
||||
fontWeight:800,color:PAPER,textBorderColor:INK,textBorderWidth:2}:undefined})),
|
||||
}],
|
||||
};
|
||||
obsReveal('mapus',elDom=>{
|
||||
const g=echarts.getInstanceByDom(elDom)||echarts.init(elDom);
|
||||
g.clear();g.setOption(opt);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ M2 · World choropleth ════
|
||||
// 世界真轮廓版。同一套 Mono 化规则;用 boundingCoords 裁掉南极,
|
||||
// 关键市场(≥39k)直接把数值标在版图上。
|
||||
(()=>{
|
||||
const D={'United States':95,'India':62,'United Kingdom':48,'Germany':41,'Brazil':39,'Canada':34,
|
||||
'France':30,'Australia':28,'China':26,'Japan':26,'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,
|
||||
'United Arab Emirates':8,'Argentina':7,'Finland':7,'Nigeria':7,'Chile':6,'Colombia':6,
|
||||
'Saudi Arabia':6,'Ukraine':6,'New Zealand':6,'Egypt':5,'Pakistan':5,'Russia':5,'Portugal':5,
|
||||
'Greece':4,'Kenya':4,'Peru':4,'Morocco':3,'Iceland':2,'Iran':2,'Kazakhstan':2,'Ethiopia':2,
|
||||
'Ghana':2,'Ecuador':2,'Algeria':2,'Mongolia':1,'Libya':1};
|
||||
const BIG={'United States':['US 95k',[0,0]],'India':['IN 62k',[0,4]],
|
||||
'United Kingdom':['UK 48k',[-26,-12]],'Germany':['DE 41k',[26,14]],'Brazil':['BR 39k',[0,0]]};
|
||||
fetch('https://cdn.jsdelivr.net/npm/[email protected]/map/json/world.json')
|
||||
.then(r=>r.json()).then(geo=>{
|
||||
echarts.registerMap('mono-world',geo);
|
||||
const opt={
|
||||
animationDuration:900,animationEasing:'quarticOut',
|
||||
tooltip:{backgroundColor:INK,borderWidth:0,padding:[10,14],
|
||||
textStyle:{color:PAPER,fontFamily:'Inter',fontSize:12},
|
||||
formatter:p=>isNaN(p.value)?p.name+' — not opened yet':p.name+' — '+p.value+'k MAU'},
|
||||
visualMap:{type:'piecewise',bottom:2,left:'center',orient:'horizontal',
|
||||
pieces:[{max:9,label:'≤9k'},{min:10,max:20,label:'10–20k'},{min:21,max:38,label:'21–38k'},
|
||||
{min:39,max:64,label:'39–64k'},{min:65,label:'65k+'}],
|
||||
...MAP_LEGEND},
|
||||
series:[{type:'map',map:'mono-world',top:4,bottom:36,
|
||||
boundingCoords:[[-170,76],[190,-58]], // crop antarctica
|
||||
itemStyle:{areaColor:'rgba(88,64,46,.10)',borderColor:PAPER,borderWidth:1.26},
|
||||
emphasis:{label:{show:true,fontFamily:'Inter',fontSize:10,fontWeight:800,color:INK,
|
||||
textBorderColor:PAPER,textBorderWidth:3},
|
||||
itemStyle:{areaColor:null,borderColor:INK,borderWidth:1.98}},
|
||||
select:{disabled:true},
|
||||
data:Object.entries(D).map(([n,v])=>({name:n,value:v,
|
||||
label:BIG[n]?{show:true,formatter:BIG[n][0],offset:BIG[n][1],fontFamily:'Inter',fontSize:8.5,
|
||||
fontWeight:800,color:INK,textBorderColor:PAPER,textBorderWidth:3}:undefined})),
|
||||
}],
|
||||
};
|
||||
obsReveal('mapworld',elDom=>{
|
||||
const g=echarts.getInstanceByDom(elDom)||echarts.init(elDom);
|
||||
g.clear();g.setOption(opt);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
addEventListener('resize',()=>{
|
||||
['mapus','mapworld'].forEach(id=>echarts.getInstanceByDom(document.getElementById(id))?.resize());
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,180 @@
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Lieflat Charts · Maps M1–M2 × PORCELAIN</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@6/dist/echarts.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--bg:#F7F2EB;--dark:#081F5C;--ink:#081F5C;--muted:rgba(8,31,92,.60);--faint:rgba(8,31,92,.32);--grid:rgba(8,31,92,.16)}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);font-family:'Inter',sans-serif;color:var(--ink);padding:40px;-webkit-font-smoothing:antialiased}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:22px;max-width:1400px;margin:0 auto}
|
||||
.card{background:var(--bg);border-radius:24px;padding:28px 28px 20px}
|
||||
.card.wide{grid-column:1/-1}
|
||||
h2{font-weight:700;font-size:16.5px;letter-spacing:-.02em;margin-bottom:3px}
|
||||
.sub{font-size:11.5px;color:var(--muted);margin-bottom:14px}
|
||||
.src{font-size:9.5px;color:var(--faint);margin-top:10px;letter-spacing:.08em;font-weight:500}
|
||||
.badge{display:inline-block;font-size:9px;font-weight:700;letter-spacing:.08em;
|
||||
padding:3px 10px;border-radius:99px;margin-bottom:10px;border:1px dashed #BAD6EB;color:#7096D1}
|
||||
svg{width:100%;max-height:330px;display:block;margin:0 auto}
|
||||
svg text{font-family:'Inter',sans-serif}
|
||||
.pop{transform-box:fill-box;transform-origin:center;animation:pop .5s cubic-bezier(.2,.7,.3,1.3) both}
|
||||
@keyframes pop{from{transform:scale(0)}to{transform:none}}
|
||||
.fade{animation:fade .9s ease both}
|
||||
@keyframes fade{from{opacity:0}}
|
||||
.draw{stroke-dasharray:1;stroke-dashoffset:1;animation:draw 1s cubic-bezier(.4,0,.2,1) both}
|
||||
@keyframes draw{to{stroke-dashoffset:0}}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.pop,.fade{animation:none}
|
||||
.draw{animation:none;stroke-dasharray:none;stroke-dashoffset:0}
|
||||
}
|
||||
.pagehead{max-width:1400px;margin:0 auto 26px;padding:0 4px}
|
||||
.pagehead h1{font-size:22px;letter-spacing:-.02em;font-weight:800}
|
||||
.pagehead p{font-size:12px;color:#7096D1;margin-top:5px;line-height:1.7}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="pagehead">
|
||||
<h1>Maps M1–M2 × PORCELAIN</h1>
|
||||
<p>青瓷蓝地图换肤样张。明度表示区域数值;仅在用户明确要求地图时使用。</p>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<!-- M1 · US Choropleth -->
|
||||
<div class="card">
|
||||
<span class="badge">MAPS · US CHOROPLETH</span>
|
||||
<h2>Sign-ups across the states</h2>
|
||||
<div class="sub">real outlines · Alaska and Hawaii tucked lower-left · shade = sign-ups, five steps · needs the network for map data</div>
|
||||
<div class="ch" id="mapus" style="height:340px"></div>
|
||||
<div class="src">CHOROPLETH · PORCELAIN · GROWTH GEO</div>
|
||||
</div>
|
||||
|
||||
<!-- M2 · World Choropleth -->
|
||||
<div class="card wide">
|
||||
<span class="badge">MAPS · WORLD CHOROPLETH</span>
|
||||
<h2>Where the users are</h2>
|
||||
<div class="sub">real outlines · shade = monthly active accounts · gray = markets we haven't opened</div>
|
||||
<div class="ch" id="mapworld" style="height:400px"></div>
|
||||
<div class="src">CHOROPLETH · PORCELAIN · GLOBAL OPS</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const INK='#081F5C',PAPER='#F7F2EB',MUTED='rgba(8,31,92,.60)',GRID='rgba(8,31,92,.16)';
|
||||
const obsReveal=(id,fn)=>{
|
||||
const n=document.getElementById(id);
|
||||
const go=()=>fn(n);
|
||||
const io=new IntersectionObserver(es=>{if(es[0].isIntersecting){go();io.disconnect()}},{threshold:.3});
|
||||
io.observe(n);
|
||||
n.style.cursor='pointer';
|
||||
n.addEventListener('click',go);
|
||||
};
|
||||
|
||||
// ════ M1 · US choropleth ════
|
||||
// 真轮廓地图:ECharts registerMap + GeoJSON(ECharts 官方示例数据,jsdelivr)。
|
||||
// 阿拉斯加/夏威夷/波多黎各用 registerMap 第三参 specialAreas 收进左下角
|
||||
// (官方 USA 示例同款,零额外依赖;d3 albersUsa 投影方案在部分环境渲染空白,弃用)。
|
||||
// Mono 化三件事:piecewise 五档灰阶 visualMap(明度即数据)、
|
||||
// 纸色描边当州界发丝、无数据州留 rgba(8,31,92,.10)(沉默可见)。
|
||||
// 面积失真的坦白:州的大小是地理,不是数值(蒙大拿大 ≠ 蒙大拿多)。
|
||||
// ⚠️ 如果换成中国地图:数据源必须含台湾省 + 南海诸岛 + 九段线
|
||||
// (用 DataV GeoAtlas 100000_full 或其完整镜像),发布前核对当期审图号。
|
||||
const MAP_LEGEND={
|
||||
itemWidth:11,itemHeight:11,itemSymbol:'rect',
|
||||
textStyle:{fontFamily:'Inter',fontSize:9,color:'#7096D1'},
|
||||
inRange:{color:['#D0E3FF','#BAD6EB','#7096D1','#334EAC','#081F5C']},
|
||||
};
|
||||
(()=>{
|
||||
const D={'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};
|
||||
fetch('https://cdn.jsdelivr.net/gh/apache/echarts-examples@gh-pages/public/data/asset/geo/USA.json')
|
||||
.then(r=>r.json()).then(geo=>{
|
||||
echarts.registerMap('mono-usa',geo,{
|
||||
Alaska:{left:-131,top:25,width:15},
|
||||
Hawaii:{left:-110,top:28,width:5},
|
||||
'Puerto Rico':{left:-76,top:26,width:2},
|
||||
});
|
||||
const opt={
|
||||
animationDuration:900,animationEasing:'quarticOut',
|
||||
tooltip:{backgroundColor:INK,borderWidth:0,padding:[10,14],
|
||||
textStyle:{color:PAPER,fontFamily:'Inter',fontSize:12},
|
||||
formatter:p=>isNaN(p.value)?p.name+' — no data':p.name+' — '+p.value+'k sign-ups'},
|
||||
visualMap:{type:'piecewise',bottom:6,left:'center',orient:'horizontal',
|
||||
pieces:[{max:9,label:'≤9k'},{min:10,max:20,label:'10–20k'},{min:21,max:38,label:'21–38k'},
|
||||
{min:39,max:64,label:'39–64k'},{min:65,label:'65k+'}],
|
||||
...MAP_LEGEND},
|
||||
series:[{type:'map',map:'mono-usa',top:10,bottom:48,
|
||||
itemStyle:{areaColor:'rgba(8,31,92,.10)',borderColor:PAPER,borderWidth:1.8},
|
||||
emphasis:{label:{show:true,fontFamily:'Inter',fontSize:10,fontWeight:800,color:INK,
|
||||
textBorderColor:PAPER,textBorderWidth:3},
|
||||
itemStyle:{areaColor:null,borderColor:INK,borderWidth:1.2}},
|
||||
select:{disabled:true},
|
||||
data:Object.entries(D).map(([n,v])=>({name:n,value:v,
|
||||
label:n==='California'?{show:true,formatter:'CA 96k',fontFamily:'Inter',fontSize:9,
|
||||
fontWeight:800,color:PAPER,textBorderColor:INK,textBorderWidth:2}:undefined})),
|
||||
}],
|
||||
};
|
||||
obsReveal('mapus',elDom=>{
|
||||
const g=echarts.getInstanceByDom(elDom)||echarts.init(elDom);
|
||||
g.clear();g.setOption(opt);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// ════ M2 · World choropleth ════
|
||||
// 世界真轮廓版。同一套 Mono 化规则;用 boundingCoords 裁掉南极,
|
||||
// 关键市场(≥39k)直接把数值标在版图上。
|
||||
(()=>{
|
||||
const D={'United States':95,'India':62,'United Kingdom':48,'Germany':41,'Brazil':39,'Canada':34,
|
||||
'France':30,'Australia':28,'China':26,'Japan':26,'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,
|
||||
'United Arab Emirates':8,'Argentina':7,'Finland':7,'Nigeria':7,'Chile':6,'Colombia':6,
|
||||
'Saudi Arabia':6,'Ukraine':6,'New Zealand':6,'Egypt':5,'Pakistan':5,'Russia':5,'Portugal':5,
|
||||
'Greece':4,'Kenya':4,'Peru':4,'Morocco':3,'Iceland':2,'Iran':2,'Kazakhstan':2,'Ethiopia':2,
|
||||
'Ghana':2,'Ecuador':2,'Algeria':2,'Mongolia':1,'Libya':1};
|
||||
const BIG={'United States':['US 95k',[0,0]],'India':['IN 62k',[0,4]],
|
||||
'United Kingdom':['UK 48k',[-26,-12]],'Germany':['DE 41k',[26,14]],'Brazil':['BR 39k',[0,0]]};
|
||||
fetch('https://cdn.jsdelivr.net/npm/[email protected]/map/json/world.json')
|
||||
.then(r=>r.json()).then(geo=>{
|
||||
echarts.registerMap('mono-world',geo);
|
||||
const opt={
|
||||
animationDuration:900,animationEasing:'quarticOut',
|
||||
tooltip:{backgroundColor:INK,borderWidth:0,padding:[10,14],
|
||||
textStyle:{color:PAPER,fontFamily:'Inter',fontSize:12},
|
||||
formatter:p=>isNaN(p.value)?p.name+' — not opened yet':p.name+' — '+p.value+'k MAU'},
|
||||
visualMap:{type:'piecewise',bottom:2,left:'center',orient:'horizontal',
|
||||
pieces:[{max:9,label:'≤9k'},{min:10,max:20,label:'10–20k'},{min:21,max:38,label:'21–38k'},
|
||||
{min:39,max:64,label:'39–64k'},{min:65,label:'65k+'}],
|
||||
...MAP_LEGEND},
|
||||
series:[{type:'map',map:'mono-world',top:4,bottom:36,
|
||||
boundingCoords:[[-170,76],[190,-58]], // crop antarctica
|
||||
itemStyle:{areaColor:'rgba(8,31,92,.10)',borderColor:PAPER,borderWidth:1.26},
|
||||
emphasis:{label:{show:true,fontFamily:'Inter',fontSize:10,fontWeight:800,color:INK,
|
||||
textBorderColor:PAPER,textBorderWidth:3},
|
||||
itemStyle:{areaColor:null,borderColor:INK,borderWidth:1.98}},
|
||||
select:{disabled:true},
|
||||
data:Object.entries(D).map(([n,v])=>({name:n,value:v,
|
||||
label:BIG[n]?{show:true,formatter:BIG[n][0],offset:BIG[n][1],fontFamily:'Inter',fontSize:8.5,
|
||||
fontWeight:800,color:INK,textBorderColor:PAPER,textBorderWidth:3}:undefined})),
|
||||
}],
|
||||
};
|
||||
obsReveal('mapworld',elDom=>{
|
||||
const g=echarts.getInstanceByDom(elDom)||echarts.init(elDom);
|
||||
g.clear();g.setOption(opt);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
addEventListener('resize',()=>{
|
||||
['mapus','mapworld'].forEach(id=>echarts.getInstanceByDom(document.getElementById(id))?.resize());
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||