rip-lang 3.12.3 → 3.12.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/demo.html ADDED
@@ -0,0 +1,1017 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>ACME Corp Dashboard — Rip</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
10
+ <script src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script>
11
+ <script type="module" src="dist/rip.min.js"></script>
12
+ <style>
13
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
14
+ body {
15
+ font-family: 'Inter', sans-serif;
16
+ font-feature-settings: "cv02", "tnum";
17
+ background: #f7f7f7;
18
+ color: #2c2c2c;
19
+ padding: 32px;
20
+ }
21
+ h1 { font-size: 24px; font-weight: 600; color: #060606; margin-bottom: 4px; }
22
+ .subtitle { font-size: 14px; color: #717171; margin-bottom: 28px; }
23
+ .section {
24
+ font-size: 13px; font-weight: 500; color: #717171;
25
+ text-transform: uppercase; letter-spacing: 0.05em;
26
+ margin: 36px 0 16px; padding-bottom: 8px;
27
+ border-bottom: 1px solid #e5e5e5;
28
+ }
29
+ .section:first-of-type { margin-top: 0; }
30
+ .grid {
31
+ display: grid;
32
+ grid-template-columns: repeat(auto-fit, minmax(540px, 1fr));
33
+ gap: 20px;
34
+ }
35
+ .card {
36
+ background: #ffffff; border-radius: 8px;
37
+ border: 1px solid #e5e5e5; padding: 20px 20px 12px;
38
+ }
39
+ .card h2 { font-size: 15px; font-weight: 500; color: #060606; margin-bottom: 2px; }
40
+ .card p { font-size: 12px; color: #717171; margin-bottom: 12px; }
41
+ .chart { width: 100%; height: 380px; }
42
+ .chart-tall { width: 100%; height: 440px; }
43
+ .wide { grid-column: 1 / -1; }
44
+ .gauge-row { display: flex; gap: 0; }
45
+ .gauge-cell { flex: 1; height: 300px; }
46
+ .kpi-row {
47
+ display: grid;
48
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
49
+ gap: 16px; margin-bottom: 8px;
50
+ }
51
+ .kpi {
52
+ background: #fff; border: 1px solid #e5e5e5; border-radius: 8px;
53
+ padding: 16px 20px; display: flex; flex-direction: column; gap: 4px;
54
+ }
55
+ .kpi-label { font-size: 12px; color: #717171; font-weight: 500; }
56
+ .kpi-row-inner { display: flex; align-items: center; gap: 10px; }
57
+ .kpi-value { font-size: 26px; font-weight: 600; color: #060606; line-height: 1.1; }
58
+ .kpi-spark { flex-shrink: 0; }
59
+ .kpi-delta { font-size: 12px; font-weight: 500; }
60
+ .kpi-delta.up { color: #16a34a; }
61
+ .kpi-delta.down { color: #dc2626; }
62
+ </style>
63
+ </head>
64
+ <body>
65
+
66
+ <!-- ===== KpiCard ===== -->
67
+
68
+ <script type="text/rip" data-name="kpi-card">
69
+ export KpiCard = component
70
+ @label := ''
71
+ @value := ''
72
+ @spark := []
73
+ @delta := ''
74
+ @up := true
75
+
76
+ sparkSvg ~=
77
+ return '' unless spark and spark.length > 1
78
+ w = 64
79
+ h = 24
80
+ lo = Math.min(...spark)
81
+ hi = Math.max(...spark)
82
+ range = hi - lo or 1
83
+ pts = spark.map((v, i) ->
84
+ x = (i / (spark.length - 1)) * w
85
+ y = h - ((v - lo) / range) * (h - 4) - 2
86
+ "#{x},#{y}"
87
+ ).join(' ')
88
+ "<svg class='kpi-spark' width='#{w}' height='#{h}' viewBox='0 0 #{w} #{h}'><polyline points='#{pts}' fill='none' stroke='#236aa4' stroke-width='1.5' stroke-linejoin='round' stroke-linecap='round'/></svg>"
89
+
90
+ render
91
+ .kpi
92
+ .kpi-label label
93
+ .kpi-row-inner
94
+ .kpi-value value
95
+ span innerHTML: sparkSvg
96
+ div class: "kpi-delta #{up ? 'up' : 'down'}"
97
+ "#{up ? '▲' : '▼'} #{delta}"
98
+ </script>
99
+
100
+ <!-- ===== ChartCard ===== -->
101
+
102
+ <script type="text/rip" data-name="chart-card">
103
+ export ChartCard = component
104
+ @title := ''
105
+ @subtitle := ''
106
+ @chartId := ''
107
+ @wide := false
108
+ @tall := false
109
+
110
+ render
111
+ div class: "card#{wide ? ' wide' : ''}"
112
+ h2 title
113
+ p subtitle
114
+ div id: chartId, class: tall ? 'chart-tall' : 'chart'
115
+ </script>
116
+
117
+ <!-- ===== Dashboard ===== -->
118
+
119
+ <script type="text/rip" data-name="index">
120
+ export Dashboard = component
121
+
122
+ # ── ECharts theme (evidence.dev) ──
123
+
124
+ THEME :=
125
+ darkMode: false
126
+ backgroundColor: '#ffffff'
127
+ textStyle: { fontFamily: ['Inter', 'sans-serif'] }
128
+ color: ['#236aa4','#45a1bf','#a5cdee','#8dacbf','#85c7c6','#d2c6ac','#f4b548','#8f3d56','#71b9f4','#46a485']
129
+ grid: { left: '1%', right: '4%', bottom: '0%', top: '15%', containLabel: true }
130
+ title:
131
+ padding: 0, itemGap: 7
132
+ textStyle: { fontSize: 14, color: '#060606' }
133
+ subtextStyle: { fontSize: 13, color: '#717171', overflow: 'break' }
134
+ top: '1px'
135
+ line: { itemStyle: { borderWidth: 0 }, lineStyle: { width: 2, join: 'round' }, symbolSize: 0, symbol: 'circle', smooth: false }
136
+ radar: { itemStyle: { borderWidth: 0 }, lineStyle: { width: 2 }, symbolSize: 0, symbol: 'circle', smooth: false }
137
+ pie: { itemStyle: { borderWidth: 0, borderColor: '#cccccc' } }
138
+ scatter: { itemStyle: { borderWidth: 0, borderColor: '#cccccc' } }
139
+ boxplot: { itemStyle: { borderWidth: 1.5 } }
140
+ parallel: { itemStyle: { borderWidth: 0, borderColor: '#cccccc' } }
141
+ sankey: { itemStyle: { borderWidth: 0, borderColor: '#cccccc' } }
142
+ funnel: { itemStyle: { borderWidth: 0, borderColor: '#cccccc' } }
143
+ gauge: { itemStyle: { borderWidth: 0, borderColor: '#cccccc' } }
144
+ candlestick: { itemStyle: { color: '#eb5454', color0: '#47b262', borderColor: '#eb5454', borderColor0: '#47b262', borderWidth: 1 } }
145
+ graph:
146
+ itemStyle: { borderWidth: 0, borderColor: '#cccccc' }
147
+ lineStyle: { width: 1, color: '#aaaaaa' }
148
+ symbolSize: 0, symbol: 'circle', smooth: false
149
+ color: ['#923d59','#488f96','#518eca','#b3a9a0','#ffc857','#495867','#bfdbf7','#bc4749','#eeebd0']
150
+ label: { color: '#f2f2f2' }
151
+ categoryAxis:
152
+ axisLine: { show: true, lineStyle: { color: '#717171' } }
153
+ axisTick: { show: false, lineStyle: { color: '#717171' }, length: 3, alignWithLabel: true }
154
+ axisLabel: { show: true, color: '#717171' }
155
+ splitLine: { show: false, lineStyle: { color: ['#d6d6d6'] } }
156
+ splitArea: { show: false }
157
+ valueAxis:
158
+ axisLine: { show: false, lineStyle: { color: '#717171' } }
159
+ axisTick: { show: false, lineStyle: { color: '#717171' }, length: 2 }
160
+ axisLabel: { show: true, color: '#717171' }
161
+ splitLine: { show: true, lineStyle: { color: ['#d6d6d6'], width: 1 } }
162
+ splitArea: { show: false }
163
+ nameTextStyle: { backgroundColor: '#ffffff' }
164
+ logAxis:
165
+ axisLine: { show: false, lineStyle: { color: '#717171' } }
166
+ axisTick: { show: false, lineStyle: { color: '#717171' }, length: 2 }
167
+ axisLabel: { show: true, color: '#717171' }
168
+ splitLine: { show: true, lineStyle: { color: ['#d6d6d6'] } }
169
+ splitArea: { show: false }
170
+ nameTextStyle: { backgroundColor: '#ffffff' }
171
+ timeAxis:
172
+ axisLine: { show: true, lineStyle: { color: '#717171' } }
173
+ axisTick: { show: true, lineStyle: { color: '#717171' }, length: 3 }
174
+ axisLabel: { show: true, color: '#717171' }
175
+ splitLine: { show: false, lineStyle: { color: ['#d6d6d6'] } }
176
+ splitArea: { show: false }
177
+ toolbox: { iconStyle: { borderColor: '#999' }, emphasis: { iconStyle: { borderColor: '#459cde' } } }
178
+ legend:
179
+ textStyle: { padding: [0,0,0,-7], color: '#717171' }, icon: 'circle'
180
+ pageIconColor: '#717171', pageIconSize: 12
181
+ pageTextStyle: { color: '#717171' }
182
+ pageButtonItemGap: -2, animationDurationUpdate: 300
183
+ tooltip:
184
+ axisPointer: { lineStyle: { color: '#ccc', width: 1 }, crossStyle: { color: '#ccc', width: 1 } }
185
+ borderRadius: 4, borderWidth: 1, borderColor: '#d6d6d6', backgroundColor: '#ffffff'
186
+ textStyle: { color: '#2c2c2c', fontSize: 12, fontWeight: 400 }, padding: 6
187
+ visualMap: { color: ['#c41621','#e39588','#f5ed98'] }
188
+ dataZoom:
189
+ type: 'slider', bottom: 10, height: 30, showDetail: false, handleSize: '80%'
190
+ borderColor: '#d6d6d6'
191
+ handleStyle: { borderColor: '#d6d6d6', color: '#d6d6d6' }
192
+ moveHandleStyle: { borderColor: '#d6d6d6', color: '#d6d6d6' }
193
+ emphasis: { handleStyle: { borderColor: '#d6d6d6', color: '#d6d6d6' }, moveHandleStyle: { borderColor: '#d6d6d6', color: '#d6d6d6' } }
194
+ markPoint: { label: { color: '#f2f2f2' }, emphasis: { label: { color: '#f2f2f2' } } }
195
+
196
+ # ── Shared option defaults ──
197
+
198
+ BASE :=
199
+ animationDuration: 500, animationDurationUpdate: 500
200
+ grid: { left: '0.8%', right: '3%', containLabel: true }
201
+ tooltip:
202
+ trigger: 'axis', show: true, confine: true
203
+ axisPointer: { type: 'shadow' }
204
+ extraCssText: 'box-shadow:0 3px 6px rgba(0,0,0,.15);box-shadow:0 2px 4px rgba(0,0,0,.12);z-index:1;font-feature-settings:"cv02","tnum";'
205
+ order: 'valueDesc'
206
+ legend: { show: true, type: 'scroll', padding: [0,0,0,0] }
207
+
208
+ XAXIS :=
209
+ type: 'category', splitLine: { show: false }, axisLine: { show: true }, axisTick: { show: false }
210
+ axisLabel: { show: true, hideOverlap: true, showMaxLabel: true, margin: 6 }, scale: true, z: 2
211
+
212
+ YAXIS :=
213
+ type: 'value', splitLine: { show: true }, axisLine: { show: false, onZero: false }, axisTick: { show: false }
214
+ axisLabel: { show: true, hideOverlap: true, margin: 4 }
215
+ nameLocation: 'end', nameTextStyle: { align: 'left', verticalAlign: 'top', padding: [0,5,0,0] }
216
+ nameGap: 6, scale: true, boundaryGap: ['0%','1%'], z: 2
217
+
218
+ COLORS := ['#236aa4','#45a1bf','#a5cdee','#8dacbf','#85c7c6','#d2c6ac','#f4b548','#8f3d56','#71b9f4','#46a485']
219
+
220
+ # ── Sample Data — ACME Corp FY 2026 ──
221
+
222
+ months := ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
223
+ quarters := ['Q1','Q2','Q3','Q4']
224
+ revenue := [420, 460, 490, 530, 560, 610, 640, 680, 720, 760, 810, 870]
225
+
226
+ products :=
227
+ 'Platform': [180,195,210,225,240,260,275,290,310,330,350,380]
228
+ 'Analytics': [120,130,135,145,150,165,170,180,190,200,215,225]
229
+ 'Integrations':[80, 90, 95,105,110,120,125,135,140,145,155,165]
230
+ 'Support': [40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90,100]
231
+
232
+ quarterlyRev := [1370, 1700, 2040, 2440]
233
+
234
+ productQuarterly :=
235
+ 'Platform': [585, 725, 875, 1060]
236
+ 'Analytics': [385, 460, 540, 640]
237
+ 'Integrations': [265, 335, 400, 465]
238
+ 'Support': [135, 180, 225, 275]
239
+
240
+ margin := [62, 64, 63, 65, 66, 65, 67, 68, 67, 69, 70, 71]
241
+ growthRate := [null, 9.5, 6.5, 8.2, 5.7, 8.9, 4.9, 6.3, 5.9, 5.6, 6.6, 7.4]
242
+
243
+ segments := [
244
+ { value: 1840, name: 'Enterprise' }
245
+ { value: 3200, name: 'Mid-Market' }
246
+ { value: 5100, name: 'SMB' }
247
+ { value: 1200, name: 'Startup' }
248
+ { value: 660, name: 'Free Tier' }
249
+ ]
250
+
251
+ deals := [
252
+ [15,82,12],[25,75,18],[35,68,22],[45,60,28],[55,55,15]
253
+ [20,78,20],[30,72,14],[40,63,24],[50,52,19],[60,45,16]
254
+ [18,80,10],[28,70,21],[38,65,26],[48,58,13],[58,48,17]
255
+ [22,76,16],[32,69,23],[42,61,20],[52,50,11],[62,42,14]
256
+ ]
257
+
258
+ days := ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']
259
+ hours := ['6a','7a','8a','9a','10a','11a','12p','1p','2p','3p','4p','5p','6p','7p','8p','9p']
260
+
261
+ heatData ~=
262
+ result = []
263
+ for d in [0...7]
264
+ for h in [0...16]
265
+ base = d < 5 ? 40 : 15
266
+ base += (d < 5 ? 50 : 10) if h >= 2 and h <= 10
267
+ base += (d < 5 ? 30 : 5) if h >= 4 and h <= 8
268
+ result.push [h, d, Math.round(base + Math.random() * 20)]
269
+ result
270
+
271
+ funnelData := [
272
+ { value: 12000, name: 'Leads' }
273
+ { value: 7200, name: 'Qualified' }
274
+ { value: 4100, name: 'Proposals' }
275
+ { value: 2400, name: 'Negotiation' }
276
+ { value: 1400, name: 'Closed Won' }
277
+ ]
278
+
279
+ sankeyNodes := [
280
+ { name: 'Organic Search' }, { name: 'Paid Search' }, { name: 'Social Media' }, { name: 'Referrals' }, { name: 'Direct' }
281
+ { name: 'Blog' }, { name: 'Landing Page' }, { name: 'Product Page' }, { name: 'Pricing Page' }
282
+ { name: 'Trial Signup' }, { name: 'Demo Request' }, { name: 'Converted' }, { name: 'Churned' }
283
+ ]
284
+
285
+ sankeyLinks := [
286
+ { source: 'Organic Search', target: 'Blog', value: 3200 }
287
+ { source: 'Organic Search', target: 'Product Page', value: 2100 }
288
+ { source: 'Paid Search', target: 'Landing Page', value: 2800 }
289
+ { source: 'Paid Search', target: 'Pricing Page', value: 1200 }
290
+ { source: 'Social Media', target: 'Blog', value: 1800 }
291
+ { source: 'Social Media', target: 'Landing Page', value: 900 }
292
+ { source: 'Referrals', target: 'Product Page', value: 1600 }
293
+ { source: 'Referrals', target: 'Pricing Page', value: 800 }
294
+ { source: 'Direct', target: 'Product Page', value: 1400 }
295
+ { source: 'Direct', target: 'Pricing Page', value: 600 }
296
+ { source: 'Blog', target: 'Trial Signup', value: 3100 }
297
+ { source: 'Blog', target: 'Demo Request', value: 1900 }
298
+ { source: 'Landing Page', target: 'Trial Signup', value: 2400 }
299
+ { source: 'Landing Page', target: 'Demo Request', value: 1300 }
300
+ { source: 'Product Page', target: 'Trial Signup', value: 2800 }
301
+ { source: 'Product Page', target: 'Demo Request', value: 2300 }
302
+ { source: 'Pricing Page', target: 'Trial Signup', value: 1500 }
303
+ { source: 'Pricing Page', target: 'Demo Request', value: 1100 }
304
+ { source: 'Trial Signup', target: 'Converted', value: 6200 }
305
+ { source: 'Trial Signup', target: 'Churned', value: 3600 }
306
+ { source: 'Demo Request', target: 'Converted', value: 4800 }
307
+ { source: 'Demo Request', target: 'Churned', value: 1800 }
308
+ ]
309
+
310
+ stateRevenue := [
311
+ { name: 'California', value: 9200 }, { name: 'New York', value: 7100 }, { name: 'Texas', value: 5800 }
312
+ { name: 'Florida', value: 4200 }, { name: 'Illinois', value: 3600 }, { name: 'Massachusetts', value: 3400 }
313
+ { name: 'Washington', value: 3100 }, { name: 'Pennsylvania', value: 2800 }, { name: 'Georgia', value: 2500 }
314
+ { name: 'Virginia', value: 2400 }, { name: 'New Jersey', value: 2200 }, { name: 'North Carolina', value: 2000 }
315
+ { name: 'Colorado', value: 1900 }, { name: 'Ohio', value: 1800 }, { name: 'Michigan', value: 1600 }
316
+ { name: 'Arizona', value: 1500 }, { name: 'Maryland', value: 1400 }, { name: 'Oregon', value: 1300 }
317
+ { name: 'Minnesota', value: 1200 }, { name: 'Connecticut', value: 1100 }, { name: 'Tennessee', value: 950 }
318
+ { name: 'Indiana', value: 850 }, { name: 'Missouri', value: 800 }, { name: 'Wisconsin', value: 750 }
319
+ { name: 'Utah', value: 700 }, { name: 'Nevada', value: 650 }, { name: 'South Carolina', value: 600 }
320
+ { name: 'Alabama', value: 500 }, { name: 'Kentucky', value: 480 }, { name: 'Louisiana', value: 460 }
321
+ { name: 'Oklahoma', value: 420 }, { name: 'Iowa', value: 400 }, { name: 'Kansas', value: 380 }
322
+ { name: 'Arkansas', value: 320 }, { name: 'Nebraska', value: 300 }, { name: 'Mississippi', value: 280 }
323
+ { name: 'New Mexico', value: 260 }, { name: 'Idaho', value: 240 }, { name: 'Hawaii', value: 220 }
324
+ { name: 'New Hampshire', value: 210 }, { name: 'Maine', value: 190 }, { name: 'Rhode Island', value: 180 }
325
+ { name: 'Delaware', value: 170 }, { name: 'Montana', value: 140 }, { name: 'Vermont', value: 130 }
326
+ { name: 'South Dakota', value: 110 }, { name: 'North Dakota', value: 100 }, { name: 'Alaska', value: 90 }
327
+ { name: 'Wyoming', value: 75 }, { name: 'West Virginia', value: 250 }, { name: 'District of Columbia', value: 1800 }
328
+ ]
329
+
330
+ clusterColors := ['#236aa4','#f4b548','#8f3d56','#46a485']
331
+ clusterNames := ['West Coast','Mountain & Central','South','Northeast']
332
+
333
+ customerLocations := [
334
+ [-122.42,37.77,0],[-118.24,34.05,0],[-122.33,47.61,0],[-121.89,37.34,0],[-117.16,32.72,0]
335
+ [-122.68,45.52,0],[-121.49,38.58,0],[-117.93,33.81,0],[-122.27,37.87,0],[-118.49,34.02,0]
336
+ [-119.70,36.75,0],[-122.03,36.97,0],[-117.39,33.95,0],[-121.94,37.35,0],[-118.16,33.77,0]
337
+ [-122.41,37.78,0],[-122.68,45.52,0],[-117.16,32.72,0],[-123.09,44.05,0],[-120.66,35.28,0]
338
+ [-118.40,33.94,0],[-122.33,47.61,0],[-121.74,36.68,0],[-116.54,33.83,0],[-122.02,37.55,0]
339
+ [-104.99,39.74,1],[-111.89,40.76,1],[-112.07,33.45,1],[-97.74,30.27,1],[-94.58,39.10,1]
340
+ [-96.80,32.78,1],[-95.37,29.76,1],[-87.63,41.88,1],[-93.27,44.98,1],[-90.20,38.63,1]
341
+ [-104.82,38.83,1],[-97.52,35.47,1],[-96.70,40.81,1],[-86.16,39.77,1],[-89.97,35.15,1]
342
+ [-105.94,35.69,1],[-106.65,35.08,1],[-111.83,33.42,1],[-104.99,39.74,1],[-95.99,36.15,1]
343
+ [-84.39,33.75,2],[-80.84,35.23,2],[-81.66,30.33,2],[-80.19,25.76,2],[-82.46,27.95,2]
344
+ [-78.64,35.78,2],[-86.78,36.16,2],[-90.07,29.95,2],[-84.51,38.05,2],[-77.44,37.54,2]
345
+ [-81.38,28.54,2],[-82.55,35.60,2],[-79.93,32.78,2],[-85.76,38.25,2],[-84.28,30.44,2]
346
+ [-80.24,25.79,2],[-81.69,41.50,2],[-76.61,39.29,2],[-77.03,38.90,2],[-83.05,42.33,2]
347
+ [-74.01,40.71,3],[-71.06,42.36,3],[-75.17,39.95,3],[-73.94,40.67,3],[-72.68,41.76,3]
348
+ [-73.76,42.65,3],[-71.41,41.82,3],[-76.15,43.05,3],[-70.26,43.66,3],[-73.79,42.66,3]
349
+ [-74.17,40.74,3],[-71.06,42.36,3],[-75.17,39.95,3],[-73.21,44.48,3],[-71.46,42.10,3]
350
+ [-73.94,40.78,3],[-74.01,40.71,3],[-72.92,41.31,3],[-75.52,39.68,3],[-71.80,42.27,3]
351
+ ]
352
+
353
+ revenueTree := [
354
+ { name: 'Platform', value: 4510, children: [
355
+ { name: 'Core Engine', value: 1800 }, { name: 'API Gateway', value: 1200 }
356
+ { name: 'Auth & SSO', value: 850 }, { name: 'Admin Console', value: 660 }
357
+ ]}
358
+ { name: 'Analytics', value: 2500, children: [
359
+ { name: 'Dashboards', value: 900 }, { name: 'Reports', value: 700 }
360
+ { name: 'Data Explorer', value: 550 }, { name: 'Alerts', value: 350 }
361
+ ]}
362
+ { name: 'Integrations', value: 1650, children: [
363
+ { name: 'CRM Sync', value: 600 }, { name: 'Slack & Teams', value: 450 }
364
+ { name: 'Webhooks', value: 350 }, { name: 'Custom API', value: 250 }
365
+ ]}
366
+ { name: 'Support', value: 890, children: [
367
+ { name: 'Live Chat', value: 380 }, { name: 'Knowledge Base', value: 280 }
368
+ { name: 'Ticketing', value: 230 }
369
+ ]}
370
+ ]
371
+
372
+ radarProducts :=
373
+ 'Platform': [92, 78, 95, 82, 88]
374
+ 'Analytics': [76, 85, 80, 74, 70]
375
+ 'Integrations': [65, 92, 72, 68, 60]
376
+ 'Support': [58, 60, 88, 90, 55]
377
+ radarMetrics := ['Revenue','Growth','Retention','NPS','Usage']
378
+
379
+ waterfallLabels := ['Q1 Base','New Customers','Upsells','Price Increase','Churn','Downgrades','Q2','New Customers','Upsells','Churn','Downgrades','Q3','New Customers','Upsells','Churn','Downgrades','Q4']
380
+ waterfallValues := [1370, 180, 220, 50, -80, -40, null, 200, 160, -60, -30, null, 240, 200, -70, -30, null]
381
+
382
+ boxplotData := [
383
+ [8, 18, 28, 42, 65]
384
+ [10, 22, 35, 52, 78]
385
+ [12, 25, 38, 55, 85]
386
+ [15, 30, 45, 62, 95]
387
+ ]
388
+
389
+ parallelData := [
390
+ ['Platform', 4510, 18.2, 95, 82, 88]
391
+ ['Analytics', 2500, 22.5, 80, 74, 70]
392
+ ['Integrations',1650, 15.8, 72, 68, 60]
393
+ ['Support', 890, 12.0, 88, 90, 55]
394
+ ]
395
+
396
+ calendarData ~=
397
+ result = []
398
+ start = new Date('2024-01-01')
399
+ for i in [0...366]
400
+ d = new Date(start)
401
+ d.setDate(d.getDate() + i)
402
+ break if d.getFullYear() > 2024
403
+ dow = d.getDay()
404
+ base = dow > 0 and dow < 6 ? 800 : 350
405
+ base += d.getMonth() * 25
406
+ base += Math.round(Math.random() * 300 - 100)
407
+ ds = d.toISOString().slice(0, 10)
408
+ result.push [ds, Math.max(50, base)]
409
+ result
410
+
411
+ histBins := ['0-50','50-100','100-150','150-200','200-300','300-500','500-1000','1000+']
412
+ histCounts := [1200, 3400, 5800, 4200, 2600, 1400, 600, 180]
413
+
414
+ features := ['Single Sign-On','API Access','Custom Reports','Slack Integration','Webhooks','Data Export','Role Permissions','Audit Log','2FA','White Label']
415
+ adoption := [94,87,76,72,65,61,58,52,48,34]
416
+
417
+ channels := ['Email','Live Chat','Phone','Social Media','Community Forum','In-App']
418
+ tickets := [3200, 2800, 1900, 1400, 1100, 2100]
419
+
420
+ cholesterolByAge := [
421
+ [11,169.5],[12,161],[13,150],[14,141.3],[15,141.4],[16,167.5],[17,154.8],[18,156.7],[19,160.4],[20,162.9]
422
+ [21,169.7],[22,164.3],[23,171.1],[24,174.7],[25,176.3],[26,179.3],[27,180.6],[28,183.9],[29,185.1],[30,185.8]
423
+ [31,189.6],[32,190],[33,190.1],[34,190.8],[35,193.1],[36,194.3],[37,195.8],[38,194],[39,195.5],[40,197.2]
424
+ [41,194.8],[42,196.2],[43,195.1],[44,193.4],[45,196.1],[46,196.4],[47,198],[48,199.5],[49,198.8],[50,197.4]
425
+ [51,198.5],[52,197.1],[53,200.9],[54,196.3],[55,198.6],[56,200.4],[57,198.3],[58,197.7],[59,197.3],[60,195.2]
426
+ [61,194.9],[62,192],[63,197.7],[64,190.1],[65,190.3],[66,189.4],[67,186.3],[68,184.6],[69,193],[70,196.7]
427
+ [71,179.9],[72,189.7],[73,195.8],[74,197.8],[75,228.1],[76,235],[77,190.9],[78,125],[79,178.5],[80,191],[85,164]
428
+ ]
429
+
430
+ cholesterolRegression ~=
431
+ n = cholesterolByAge.length
432
+ sx = cholesterolByAge.reduce(((s, d) -> s + d[0]), 0)
433
+ sy = cholesterolByAge.reduce(((s, d) -> s + d[1]), 0)
434
+ sxy = cholesterolByAge.reduce(((s, d) -> s + d[0] * d[1]), 0)
435
+ sx2 = cholesterolByAge.reduce(((s, d) -> s + d[0] * d[0]), 0)
436
+ slope = (n * sxy - sx * sy) / (n * sx2 - sx * sx)
437
+ intercept = (sy - slope * sx) / n
438
+ xMin = Math.min(...cholesterolByAge.map(-> it[0]))
439
+ xMax = Math.max(...cholesterolByAge.map(-> it[0]))
440
+ { slope, intercept, xMin, xMax }
441
+
442
+ cholesterolWithStd := [
443
+ [11,169.5,57.3],[12,161,21.6],[13,150,43.6],[14,141.3,21.8],[15,141.4,27.1],[16,167.5,27],[17,154.8,25.5],[18,156.7,36.3],[19,160.4,34.8],[20,162.9,31]
444
+ [21,169.7,32.4],[22,164.3,31.9],[23,171.1,34.3],[24,174.7,34.7],[25,176.3,32.5],[26,179.3,33.7],[27,180.6,32.9],[28,183.9,35.1],[29,185.1,36.4],[30,185.8,36.9]
445
+ [31,189.6,37.3],[32,190,36.1],[33,190.1,35.9],[34,190.8,35.8],[35,193.1,37.1],[36,194.3,36.6],[37,195.8,38.1],[38,194,37.4],[39,195.5,36.5],[40,197.2,38.4]
446
+ [41,194.8,40.6],[42,196.2,38.2],[43,195.1,36.9],[44,193.4,41.2],[45,196.1,38.6],[46,196.4,40],[47,198,39.8],[48,199.5,42.9],[49,198.8,42.3],[50,197.4,40.5]
447
+ [51,198.5,42.1],[52,197.1,41.7],[53,200.9,45.1],[54,196.3,42.2],[55,198.6,41.9],[56,200.4,45.5],[57,198.3,50.5],[58,197.7,49.7],[59,197.3,45.2],[60,195.2,47]
448
+ [61,194.9,43.5],[62,192,46.5],[63,197.7,42.4],[64,190.1,45.1],[65,190.3,45.5],[66,189.4,51],[67,186.3,46.5],[68,184.6,44.9],[69,193,46],[70,196.7,57.7]
449
+ [71,179.9,49.8],[72,189.7,59.2],[73,195.8,40.7],[74,197.8,40.1],[75,228.1,52],[76,235,0],[77,190.9,37.8],[78,125,0],[79,178.5,29.4],[80,191,0],[85,164,0]
450
+ ]
451
+
452
+ a1cValues := [4.2,4.3,4.4,4.5,4.6,4.7,4.8,4.9,5.0,5.1,5.2,5.3,5.4,5.5,5.6,5.7,5.8,5.9,6.0,6.1,6.2,6.3,6.4,6.5,6.6,6.7,6.8,6.9,7.0,7.1,7.2,7.3,7.4,7.5,7.6,7.7,7.8,7.9,8.0,8.1,8.2,8.3,8.4,8.5,8.6,8.7,8.8,8.9,9.0,9.1,9.2,9.3,9.4,9.5,9.6,9.7,9.8,9.9,10.0,10.1,10.2,10.3,10.4,10.5,10.6,10.7,10.8,10.9,11.0,11.1,11.2,11.3,11.4,11.5,11.6,11.7,11.8,11.9,12.0,12.1,12.2,12.3,12.4,12.5,12.6,12.7,12.8,12.9,13.0,13.1,13.2,13.3,13.4,13.5,13.6,13.7,13.8,13.9,14.0,14.1,14.2,14.3,14.4,14.5,14.6,14.7,14.8,15.0,15.1,15.2,15.3,15.4]
453
+ a1cCounts := [12,35,49,82,169,339,598,1137,1983,3060,4349,5225,5955,6122,5530,4704,3755,2660,1791,1256,911,619,492,385,306,263,202,214,192,195,150,136,103,106,101,81,84,84,67,75,64,61,51,47,52,60,34,39,42,35,47,33,24,37,30,15,26,22,11,25,29,18,16,15,17,20,17,22,18,17,21,17,19,11,14,13,22,8,12,12,9,17,13,7,6,14,4,6,6,7,9,3,7,4,8,5,3,6,2,1,4,2,1,3,2,3,2,1,1,2,2,1]
454
+
455
+ cohortScatter ~=
456
+ result = []
457
+ for i in [0...40]
458
+ eng = 20 + Math.random() * 75
459
+ ret = 35 + eng * 0.65 + (Math.random() - 0.5) * 25
460
+ result.push [Math.round(eng * 10) / 10, Math.min(99, Math.max(30, Math.round(ret * 10) / 10))]
461
+ result.sort((a, b) -> a[0] - b[0])
462
+ result
463
+
464
+ regression ~=
465
+ n = cohortScatter.length
466
+ sx = cohortScatter.reduce(((s, d) -> s + d[0]), 0)
467
+ sy = cohortScatter.reduce(((s, d) -> s + d[1]), 0)
468
+ sxy = cohortScatter.reduce(((s, d) -> s + d[0] * d[1]), 0)
469
+ sx2 = cohortScatter.reduce(((s, d) -> s + d[0] * d[0]), 0)
470
+ slope = (n * sxy - sx * sy) / (n * sx2 - sx * sx)
471
+ intercept = (sy - slope * sx) / n
472
+ xMin = Math.min(...cohortScatter.map(-> it[0]))
473
+ xMax = Math.max(...cohortScatter.map(-> it[0]))
474
+ { slope, intercept, xMin, xMax }
475
+
476
+ kpis := [
477
+ { label: 'Monthly Revenue', value: '$870k', spark: [420,460,490,530,560,610,640,680,720,760,810,870], delta: '+7.4% vs. prior month', up: true }
478
+ { label: 'Total Customers', value: '12,000', spark: [8200,8600,9100,9400,9800,10200,10500,10900,11200,11500,11700,12000], delta: '+2.6% vs. prior month', up: true }
479
+ { label: 'Avg Deal Size', value: '$38k', spark: [32,30,34,33,36,35,37,36,38,37,39,38], delta: '-2.6% vs. prior month', up: false }
480
+ { label: 'Net Revenue Retention', value: '118%', spark: [108,110,112,111,114,113,115,114,116,117,117,118], delta: '+0.9% vs. prior month', up: true }
481
+ { label: 'Churn Rate', value: '1.8%', spark: [3.2,3.0,2.8,2.6,2.5,2.4,2.3,2.2,2.1,2.0,1.9,1.8], delta: '-5.3% vs. prior month', up: true }
482
+ ]
483
+
484
+ # ── Helpers ──
485
+
486
+ $k = (v) -> "$#{v}k"
487
+ pct = (v) -> "#{v}%"
488
+ $M = (v) -> "$#{(v / 1000).toFixed(1)}M"
489
+ valK = (p) -> "#{p.name}<br>$#{(p.value or 0).toLocaleString()}k"
490
+ tip = (fmt) -> { ...BASE.tooltip, trigger: 'item', formatter: fmt }
491
+
492
+ xaxis = (data = null, opts = {}) ->
493
+ if typeof data is 'object' and not Array.isArray(data) and data isnt null
494
+ opts = data
495
+ data = opts.data or null
496
+ label = if opts.axisLabel then { ...XAXIS.axisLabel, ...opts.axisLabel } else XAXIS.axisLabel
497
+ { ...XAXIS, data, ...opts, axisLabel: label }
498
+ yaxis = (name, fmt, opts = {}) ->
499
+ label = if opts.axisLabel then { ...YAXIS.axisLabel, formatter: fmt, ...opts.axisLabel } else { ...YAXIS.axisLabel, formatter: fmt }
500
+ { ...YAXIS, name, ...opts, axisLabel: label }
501
+
502
+ ic: (id, opts) ->
503
+ el = document.getElementById(id)
504
+ return unless el
505
+ c = echarts.init(el, 'evidence')
506
+ c.setOption({ ...BASE, ...opts })
507
+ window.addEventListener 'resize', -> c.resize({ animation: { duration: 500 } })
508
+ c
509
+
510
+ buildWaterfall: (labels, values) ->
511
+ base = []
512
+ inc = []
513
+ dec = []
514
+ running = 0
515
+ for i in [0...values.length]
516
+ v = values[i]
517
+ if v is null
518
+ base.push 0
519
+ inc.push running
520
+ dec.push 0
521
+ else if i is 0
522
+ running = v
523
+ base.push 0
524
+ inc.push v
525
+ dec.push 0
526
+ else if v >= 0
527
+ base.push running
528
+ inc.push v
529
+ dec.push 0
530
+ running += v
531
+ else
532
+ running += v
533
+ base.push running
534
+ inc.push 0
535
+ dec.push -v
536
+ { base, inc, dec }
537
+
538
+ makeGauge: (id, value, label, fmt, color, max) ->
539
+ @ic id,
540
+ tooltip: { show: false }
541
+ legend: { show: false }
542
+ series: [{
543
+ type: 'gauge', startAngle: 200, endAngle: -20, min: 0, max: max or 100
544
+ pointer: { show: false }
545
+ progress: { show: true, width: 16, roundCap: true, itemStyle: { color } }
546
+ axisLine: { lineStyle: { width: 16, color: [[1, '#e5e5e5']] } }
547
+ axisTick: { show: false }, splitLine: { show: false }, axisLabel: { show: false }, anchor: { show: false }
548
+ detail:
549
+ valueAnimation: true, fontSize: 26, fontWeight: 600, fontFamily: 'Inter'
550
+ color: '#060606', offsetCenter: [0, '10%'], formatter: fmt
551
+ title: { fontSize: 13, fontWeight: 500, color: '#717171', offsetCenter: [0, '55%'] }
552
+ data: [{ value, name: label }]
553
+ }]
554
+
555
+ # ── Chart initialization ──
556
+
557
+ initCoreCharts: ->
558
+ @ic 'line',
559
+ xAxis: xaxis months
560
+ yAxis: yaxis 'Revenue ($k)', $k
561
+ series: [{
562
+ type: 'line', symbol: 'circle', symbolSize: 0, smooth: false
563
+ lineStyle: { width: 2, type: 'solid' }
564
+ emphasis: { focus: 'series', lineStyle: { opacity: 1, width: 3 } }
565
+ data: revenue
566
+ }]
567
+
568
+ @ic 'area',
569
+ xAxis: xaxis months
570
+ yAxis: yaxis 'Revenue ($k)', $k
571
+ series: ({
572
+ type: 'line', name, data
573
+ symbol: 'circle', symbolSize: 0, smooth: false
574
+ lineStyle: { width: 1 }, areaStyle: { opacity: 0.45 }
575
+ stack: 'revenue', emphasis: { focus: 'series' }
576
+ } for name, data of products)
577
+
578
+ @ic 'bar',
579
+ xAxis: xaxis quarters
580
+ yAxis: yaxis 'Revenue ($k)', $M
581
+ series: [{ type: 'bar', barMaxWidth: 60, label: { fontSize: 11 }, data: quarterlyRev }]
582
+
583
+ @ic 'stackedBar',
584
+ xAxis: xaxis quarters
585
+ yAxis: yaxis 'Revenue ($k)', $k
586
+ series: ({ type: 'bar', name, data, stack: 'total', barMaxWidth: 60, emphasis: { focus: 'series' } } for name, data of productQuarterly)
587
+
588
+ @ic 'combo',
589
+ xAxis: xaxis months
590
+ yAxis: [
591
+ yaxis 'Revenue ($k)', $k
592
+ yaxis 'Margin %', pct, position: 'right', splitLine: { show: false }, min: 50, max: 80
593
+ ]
594
+ series: [
595
+ { type: 'bar', name: 'Revenue', data: revenue, barMaxWidth: 60, yAxisIndex: 0 }
596
+ { type: 'line', name: 'Gross Margin', data: margin, yAxisIndex: 1, symbol: 'circle', symbolSize: 0, lineStyle: { width: 2 }, emphasis: { focus: 'series', lineStyle: { opacity: 1, width: 3 } } }
597
+ ]
598
+
599
+ @ic 'multiAxis',
600
+ xAxis: xaxis months
601
+ yAxis: [
602
+ yaxis 'Revenue ($k)', $k
603
+ yaxis 'MoM Growth %', pct, position: 'right', splitLine: { show: false }
604
+ ]
605
+ series: [
606
+ { type: 'line', name: 'Revenue', data: revenue, symbol: 'circle', symbolSize: 0, lineStyle: { width: 2 }, emphasis: { focus: 'series', lineStyle: { opacity: 1, width: 3 } } }
607
+ { type: 'line', name: 'Growth %', data: growthRate, yAxisIndex: 1, symbol: 'circle', symbolSize: 6, lineStyle: { width: 2, type: 'dashed' }, emphasis: { focus: 'series', lineStyle: { opacity: 1, width: 3 } } }
608
+ ]
609
+
610
+ @ic 'pie',
611
+ tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' }
612
+ series: [{
613
+ type: 'pie', radius: ['40%','70%'], center: ['50%','55%']
614
+ padAngle: 2, itemStyle: { borderRadius: 4 }, label: { fontSize: 12 }
615
+ data: segments
616
+ }]
617
+
618
+ @ic 'scatter',
619
+ xAxis: xaxis type: 'value', name: 'Avg Deal Size ($k)', axisLabel: { formatter: $k }, axisLine: { show: true }
620
+ yAxis: yaxis 'Close Rate %', pct
621
+ tooltip: tip (p) -> "Deal: $#{p.value[0]}k<br>Close Rate: #{p.value[1]}%<br>Deals: #{p.value[2]}"
622
+ series: [{
623
+ type: 'scatter', data: deals, symbolSize: (d) -> d[2] * 1.8
624
+ emphasis: { focus: 'self', itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.3)' } }
625
+ }]
626
+
627
+ @ic 'heatmap',
628
+ grid: { ...BASE.grid, top: '8%', bottom: '12%' }
629
+ xAxis: xaxis hours, splitArea: { show: false }
630
+ yAxis: { ...YAXIS, type: 'category', data: days, splitLine: { show: false }, splitArea: { show: false } }
631
+ tooltip: tip (p) -> "#{days[p.value[1]]} #{hours[p.value[0]]}<br>Sessions: <b>#{p.value[2]}</b>"
632
+ visualMap:
633
+ min: 10, max: 140, calculable: true, orient: 'horizontal', left: 'center', bottom: '0%'
634
+ inRange: { color: ['#a5cdee','#236aa4'] }
635
+ textStyle: { color: '#717171', fontSize: 11 }
636
+ series: [{ type: 'heatmap', data: heatData, label: { show: false }, emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.3)' } } }]
637
+
638
+ @ic 'funnel',
639
+ tooltip: { trigger: 'item', formatter: '{b}: {c}' }
640
+ series: [{
641
+ type: 'funnel', left: '10%', right: '10%', top: '12%', bottom: '8%', width: '80%'
642
+ sort: 'descending', gap: 4
643
+ label: { show: true, position: 'inside', fontSize: 13, color: '#fff' }
644
+ emphasis: { label: { fontSize: 15 } }
645
+ data: funnelData
646
+ }]
647
+
648
+ @ic 'sankey',
649
+ tooltip: { trigger: 'item', triggerOn: 'mousemove' }
650
+ series: [{
651
+ type: 'sankey', layout: 'none'
652
+ left: '3%', right: '10%', top: '8%', bottom: '8%'
653
+ nodeWidth: 20, nodeGap: 14, layoutIterations: 32
654
+ emphasis: { focus: 'adjacency' }
655
+ lineStyle: { color: 'gradient', curveness: 0.5 }
656
+ label: { fontSize: 12 }
657
+ data: sankeyNodes, links: sankeyLinks
658
+ }]
659
+
660
+ initMaps: ->
661
+ try
662
+ usaJson = fetch!('https://raw.githubusercontent.com/apache/echarts-examples/gh-pages/public/data/asset/geo/USA.json').json!
663
+ echarts.registerMap 'USA', usaJson,
664
+ 'Alaska': { left: -131, top: 25, width: 15 }
665
+ 'Hawaii': { left: -110, top: 28, width: 5 }
666
+ 'Puerto Rico': { left: -76, top: 26, width: 2 }
667
+
668
+ @ic 'choropleth',
669
+ tooltip: { trigger: 'item', formatter: (p) -> "#{p.name}<br>Revenue: $#{(p.value or 0).toLocaleString()}k" }
670
+ visualMap:
671
+ min: 50, max: 9500, text: ['$9.5M+','$50k']
672
+ inRange: { color: ['#a5cdee','#45a1bf','#236aa4'] }
673
+ calculable: true, orient: 'horizontal', left: 'center', bottom: '2%'
674
+ textStyle: { color: '#717171', fontSize: 11 }
675
+ series: [{
676
+ type: 'map', map: 'USA', roam: true
677
+ emphasis: { label: { show: true, fontSize: 11, color: '#060606' }, itemStyle: { areaColor: '#f4b548' } }
678
+ label: { show: false }
679
+ itemStyle: { areaColor: '#e5e5e5', borderColor: '#fff', borderWidth: 1 }
680
+ data: stateRevenue
681
+ }]
682
+
683
+ @ic 'scatterMap',
684
+ tooltip:
685
+ trigger: 'item'
686
+ formatter: (p) ->
687
+ return "#{clusterNames[p.value[2]]} cluster" if p.seriesType is 'scatter'
688
+ p.name
689
+ legend:
690
+ show: true, top: '2%'
691
+ data: clusterNames.map((n, i) -> { name: n, icon: 'circle', itemStyle: { color: clusterColors[i] } })
692
+ geo:
693
+ map: 'USA', roam: true
694
+ itemStyle: { areaColor: '#f0f0f0', borderColor: '#d6d6d6', borderWidth: 0.5 }
695
+ emphasis: { itemStyle: { areaColor: '#e5e5e5' }, label: { show: false } }
696
+ label: { show: false }
697
+ series: clusterNames.map((name, ci) -> {
698
+ type: 'scatter', name, coordinateSystem: 'geo'
699
+ data: customerLocations.filter((d) -> d[2] is ci).map((d) -> { value: [d[0], d[1], d[2]] })
700
+ symbolSize: 8
701
+ itemStyle: { color: clusterColors[ci], opacity: 0.8 }
702
+ emphasis: { itemStyle: { opacity: 1, shadowBlur: 5, shadowColor: 'rgba(0,0,0,0.3)' } }
703
+ })
704
+ catch
705
+ for id in ['choropleth', 'scatterMap']
706
+ el = document.getElementById(id)
707
+ el.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#717171;font-size:13px;">Map data could not be loaded (requires internet)</div>' if el
708
+
709
+ initHierarchicalCharts: ->
710
+ @ic 'treemap',
711
+ tooltip: { trigger: 'item', formatter: valK }
712
+ legend: { show: false }
713
+ series: [{
714
+ type: 'treemap', data: revenueTree
715
+ top: '4%', bottom: '2%', left: '2%', right: '2%'
716
+ roam: false, nodeClick: false, breadcrumb: { show: false }
717
+ label: { show: true, fontSize: 12, color: '#fff', fontWeight: 500 }
718
+ upperLabel: { show: true, height: 24, fontSize: 12, fontWeight: 600, color: '#fff' }
719
+ itemStyle: { borderColor: '#fff', borderWidth: 2, gapWidth: 2 }
720
+ levels: [
721
+ { itemStyle: { borderWidth: 3, gapWidth: 4, borderColor: '#fff' }, upperLabel: { show: true } }
722
+ { itemStyle: { borderWidth: 2, gapWidth: 2, borderColor: '#fff' }, colorSaturation: [0.3, 0.7] }
723
+ ]
724
+ }]
725
+
726
+ @ic 'sunburst',
727
+ tooltip: { trigger: 'item', formatter: valK }
728
+ legend: { show: false }
729
+ series: [{
730
+ type: 'sunburst', data: revenueTree, radius: ['15%','90%'], sort: null
731
+ emphasis: { focus: 'ancestor' }
732
+ itemStyle: { borderRadius: 4, borderWidth: 2, borderColor: '#fff' }
733
+ label: { fontSize: 11, minAngle: 15 }
734
+ levels: [
735
+ {}
736
+ { r0: '15%', r: '45%', label: { fontSize: 13, fontWeight: 500 } }
737
+ { r0: '45%', r: '90%', label: { fontSize: 10 } }
738
+ ]
739
+ }]
740
+
741
+ initAnalyticalCharts: ->
742
+ @ic 'radar',
743
+ tooltip: { trigger: 'item' }
744
+ radar:
745
+ indicator: radarMetrics.map((m) -> { name: m, max: 100 })
746
+ shape: 'polygon', splitNumber: 4
747
+ axisName: { color: '#717171', fontSize: 12 }
748
+ splitLine: { lineStyle: { color: '#e5e5e5' } }
749
+ splitArea: { areaStyle: { color: ['#fff','#fafafa'] } }
750
+ axisLine: { lineStyle: { color: '#e5e5e5' } }
751
+ series: [{
752
+ type: 'radar'
753
+ data: ({ name, value } for name, value of radarProducts)
754
+ areaStyle: { opacity: 0.15 }, lineStyle: { width: 2 }
755
+ symbol: 'circle', symbolSize: 5
756
+ }]
757
+
758
+ wf = @buildWaterfall(waterfallLabels, waterfallValues)
759
+ totals = [0, 6, 11, 16]
760
+ @ic 'waterfall',
761
+ xAxis: xaxis waterfallLabels, axisLabel: { rotate: 45, fontSize: 10 }
762
+ yAxis: yaxis 'Revenue ($k)', $k
763
+ legend: { show: false }
764
+ series: [
765
+ { type: 'bar', stack: 'wf', name: 'base', data: wf.base, barMaxWidth: 36, itemStyle: { color: 'transparent' }, emphasis: { itemStyle: { color: 'transparent' } } }
766
+ { type: 'bar', stack: 'wf', name: 'Increase', barMaxWidth: 36, data: wf.inc.map((v, i) -> { value: v, itemStyle: { color: totals.includes(i) ? '#236aa4' : '#46a485' } }), label: { show: true, position: 'top', fontSize: 10, color: '#717171', formatter: (p) -> v = waterfallValues[p.dataIndex]; return "$#{p.value}k" if v is null; return "+$#{v}k" if v > 0; '' } }
767
+ { type: 'bar', stack: 'wf', name: 'Decrease', data: wf.dec, barMaxWidth: 36, itemStyle: { color: '#8f3d56' }, label: { show: true, position: 'bottom', fontSize: 10, color: '#717171', formatter: (p) -> p.value > 0 ? "-$#{p.value}k" : '' } }
768
+ ]
769
+
770
+ @ic 'boxplot',
771
+ xAxis: xaxis quarters
772
+ yAxis: yaxis 'Deal Size ($k)', $k
773
+ tooltip: tip (p) -> "#{p.name}<br>Max: $#{p.value[5]}k<br>Q3: $#{p.value[4]}k<br>Median: $#{p.value[3]}k<br>Q1: $#{p.value[2]}k<br>Min: $#{p.value[1]}k"
774
+ series: [{
775
+ type: 'boxplot', data: boxplotData
776
+ itemStyle: { color: '#a5cdee', borderColor: '#236aa4', borderWidth: 1.5 }
777
+ emphasis: { itemStyle: { borderColor: '#060606', borderWidth: 2 } }
778
+ }]
779
+
780
+ @ic 'parallel',
781
+ tooltip: { show: false }
782
+ legend: { show: true, data: parallelData.map(-> it[0]) }
783
+ parallelAxis: [
784
+ { dim: 0, name: 'Product', type: 'category', data: parallelData.map(-> it[0]), nameLocation: 'start' }
785
+ { dim: 1, name: 'Revenue ($k)', type: 'value', nameLocation: 'end' }
786
+ { dim: 2, name: 'Growth %', type: 'value', nameLocation: 'end' }
787
+ { dim: 3, name: 'Retention %', type: 'value', min: 50, max: 100, nameLocation: 'end' }
788
+ { dim: 4, name: 'NPS', type: 'value', min: 50, max: 100, nameLocation: 'end' }
789
+ { dim: 5, name: 'Usage Score', type: 'value', min: 40, max: 100, nameLocation: 'end' }
790
+ ]
791
+ parallel:
792
+ left: '5%', right: '5%', top: '18%', bottom: '12%'
793
+ parallelAxisDefault:
794
+ nameTextStyle: { color: '#717171', fontSize: 11 }
795
+ axisLine: { lineStyle: { color: '#d6d6d6' } }
796
+ axisTick: { show: false }
797
+ axisLabel: { color: '#717171', fontSize: 10 }
798
+ splitLine: { show: false }
799
+ series: parallelData.map((d, i) -> {
800
+ type: 'parallel', name: d[0]
801
+ lineStyle: { width: 3, opacity: 0.7, color: COLORS[i] }
802
+ emphasis: { lineStyle: { width: 5, opacity: 1 } }
803
+ data: [d]
804
+ })
805
+
806
+ @ic 'calendar',
807
+ tooltip: { trigger: 'item', formatter: (p) -> "#{p.value[0]}<br>Logins: #{p.value[1].toLocaleString()}" }
808
+ legend: { show: false }
809
+ visualMap:
810
+ min: 200, max: 1400, calculable: true, orient: 'horizontal', left: 'center', bottom: '2%'
811
+ inRange: { color: ['#a5cdee','#45a1bf','#236aa4'] }
812
+ textStyle: { color: '#717171', fontSize: 11 }
813
+ calendar:
814
+ range: '2024', top: '12%', left: '8%', right: '4%', bottom: '18%'
815
+ cellSize: ['auto', 16]
816
+ itemStyle: { borderWidth: 2, borderColor: '#fff' }
817
+ splitLine: { lineStyle: { color: '#d6d6d6', width: 1 } }
818
+ yearLabel: { show: false }
819
+ monthLabel: { color: '#717171', fontSize: 11, nameMap: 'en' }
820
+ dayLabel: { color: '#717171', fontSize: 10, firstDay: 1, nameMap: ['','M','','W','','F',''] }
821
+ series: [{
822
+ type: 'heatmap', coordinateSystem: 'calendar', data: calendarData
823
+ emphasis: { itemStyle: { shadowBlur: 5, shadowColor: 'rgba(0,0,0,0.3)' } }
824
+ }]
825
+
826
+ initAdditionalCharts: ->
827
+ @ic 'histogram',
828
+ xAxis: xaxis histBins, name: 'Response Time (ms)', axisLabel: { fontSize: 10 }
829
+ yAxis: yaxis 'Request Count', (v) -> v >= 1000 ? "#{v / 1000}k" : v
830
+ legend: { show: false }
831
+ series: [{
832
+ type: 'bar', barWidth: '90%'
833
+ data: histCounts.map((v, i) -> { value: v, itemStyle: { color: i <= 2 ? '#46a485' : i <= 4 ? '#f4b548' : '#8f3d56' } })
834
+ emphasis: { itemStyle: { opacity: 0.85 } }
835
+ }]
836
+
837
+ @ic 'horizBar',
838
+ grid: { ...BASE.grid, left: '2%' }
839
+ xAxis: yaxis 'Adoption %', pct, max: 100
840
+ yAxis: xaxis features.slice().reverse(), type: 'category', axisLabel: { fontSize: 11 }, inverse: false
841
+ legend: { show: false }
842
+ tooltip: tip (p) -> "#{p.name}: #{p.value}%"
843
+ series: [{
844
+ type: 'bar', data: adoption.slice().reverse(), barMaxWidth: 20
845
+ itemStyle: { borderRadius: [0,3,3,0] }
846
+ label: { show: true, position: 'right', fontSize: 11, color: '#717171', formatter: (p) -> "#{p.value}%" }
847
+ emphasis: { itemStyle: { opacity: 0.85 } }
848
+ }]
849
+
850
+ @ic 'polar',
851
+ tooltip: { trigger: 'item', formatter: '{b}: {c} tickets' }
852
+ legend: { show: true, type: 'scroll', bottom: '2%' }
853
+ angleAxis:
854
+ type: 'category', data: channels, startAngle: 90
855
+ axisLine: { lineStyle: { color: '#d6d6d6' } }
856
+ axisLabel: { color: '#717171', fontSize: 11 }
857
+ radiusAxis:
858
+ max: 3500, axisLine: { show: false }, axisTick: { show: false }
859
+ axisLabel: { show: false }
860
+ splitLine: { lineStyle: { color: '#e5e5e5' } }
861
+ polar: { radius: ['10%','75%'] }
862
+ series: [{
863
+ type: 'bar', coordinateSystem: 'polar', roundCap: true
864
+ data: tickets.map((v, i) -> { value: v, itemStyle: { color: COLORS[i] } })
865
+ emphasis: { focus: 'self' }, label: { show: false }
866
+ }]
867
+
868
+ r = regression
869
+ @ic 'scatterReg',
870
+ xAxis: xaxis type: 'value', name: 'Engagement Score', axisLine: { show: true }
871
+ yAxis: yaxis 'Retention %', pct
872
+ tooltip: tip (p) -> p.seriesType is 'scatter' ? "Engagement: #{p.value[0]}<br>Retention: #{p.value[1]}%" : 'Trend line'
873
+ legend: { show: false }
874
+ series: [
875
+ { type: 'scatter', data: cohortScatter, symbolSize: 9, itemStyle: { color: '#236aa4', opacity: 0.7 }, emphasis: { itemStyle: { opacity: 1, shadowBlur: 8, shadowColor: 'rgba(0,0,0,0.25)' } } }
876
+ { type: 'line', data: [[r.xMin, r.slope * r.xMin + r.intercept], [r.xMax, r.slope * r.xMax + r.intercept]], symbol: 'none', lineStyle: { width: 2, type: 'dashed', color: '#8f3d56' }, tooltip: { show: false } }
877
+ ]
878
+
879
+ @ic 'a1c',
880
+ xAxis: xaxis a1cValues, name: 'Hemoglobin A1c (%)', axisLabel: { fontSize: 10, rotate: 45 }
881
+ yAxis: yaxis 'People', (v) -> v >= 1000 ? "#{(v / 1000).toFixed(1)}k" : v
882
+ legend: { show: false }
883
+ tooltip: tip (p) -> "A1c #{p.name}%<br>Count: #{p.value.toLocaleString()}"
884
+ series: [{
885
+ type: 'bar', data: a1cCounts.map((v, i) -> {
886
+ value: v
887
+ itemStyle: { color: a1cValues[i] < 5.7 ? '#46a485' : a1cValues[i] < 6.5 ? '#f4b548' : '#8f3d56' }
888
+ })
889
+ barWidth: '90%'
890
+ emphasis: { itemStyle: { opacity: 0.85 } }
891
+ }]
892
+
893
+ cr = cholesterolRegression
894
+ @ic 'cholesterol',
895
+ xAxis: xaxis type: 'value', name: 'Age (years)', axisLine: { show: true }
896
+ yAxis: yaxis 'Avg Total Cholesterol (mg/dL)', (v) -> "#{v}"
897
+ tooltip: tip (p) -> p.seriesType is 'scatter' ? "Age #{p.value[0]}<br>Avg Cholesterol: #{p.value[1]} mg/dL" : 'Trend line'
898
+ legend: { show: false }
899
+ series: [
900
+ { type: 'scatter', data: cholesterolByAge, symbolSize: 9, itemStyle: { color: '#236aa4', opacity: 0.7 }, emphasis: { itemStyle: { opacity: 1, shadowBlur: 8, shadowColor: 'rgba(0,0,0,0.25)' } } }
901
+ { type: 'line', data: [[cr.xMin, cr.slope * cr.xMin + cr.intercept], [cr.xMax, cr.slope * cr.xMax + cr.intercept]], symbol: 'none', lineStyle: { width: 2, type: 'dashed', color: '#8f3d56' }, tooltip: { show: false } }
902
+ ]
903
+
904
+ errorBarRenderer = (params, api) ->
905
+ age = api.value(0)
906
+ avg = api.value(1)
907
+ std = api.value(2)
908
+ return null if std is 0
909
+ hi = api.coord([age, avg + std])
910
+ lo = api.coord([age, avg - std])
911
+ cap = 4
912
+ { type: 'group', children: [
913
+ { type: 'line', shape: { x1: hi[0], y1: hi[1], x2: lo[0], y2: lo[1] }, style: { stroke: '#236aa4', lineWidth: 1.5 } }
914
+ { type: 'line', shape: { x1: hi[0] - cap, y1: hi[1], x2: hi[0] + cap, y2: hi[1] }, style: { stroke: '#236aa4', lineWidth: 1.5 } }
915
+ { type: 'line', shape: { x1: lo[0] - cap, y1: lo[1], x2: lo[0] + cap, y2: lo[1] }, style: { stroke: '#236aa4', lineWidth: 1.5 } }
916
+ ]}
917
+
918
+ @ic 'cholesterolBand',
919
+ xAxis: xaxis type: 'value', name: 'Age (years)', axisLine: { show: true }
920
+ yAxis: yaxis 'Total Cholesterol (mg/dL)', (v) -> "#{v}", min: 80
921
+ tooltip: tip (p) ->
922
+ return 'Trend line' if p.seriesName is 'Trend'
923
+ return '' if p.seriesName is 'Error'
924
+ d = cholesterolWithStd.find(-> it[0] is p.value[0])
925
+ return "Age #{p.value[0]}<br>Avg: #{p.value[1]} mg/dL" unless d and d[2] > 0
926
+ "Age #{d[0]}<br>Avg: #{d[1]} mg/dL<br>\u00b1#{d[2]} std dev"
927
+ legend: { show: false }
928
+ series: [
929
+ { type: 'custom', name: 'Error', data: cholesterolWithStd, renderItem: errorBarRenderer, z: 1 }
930
+ { type: 'scatter', name: 'Average', data: cholesterolWithStd.map(-> [it[0], it[1]]), symbolSize: 7, itemStyle: { color: '#236aa4' }, z: 3 }
931
+ { type: 'line', name: 'Trend', data: [[cr.xMin, cr.slope * cr.xMin + cr.intercept], [cr.xMax, cr.slope * cr.xMax + cr.intercept]], symbol: 'none', lineStyle: { width: 2, type: 'dashed', color: '#8f3d56' }, z: 2, tooltip: { show: false } }
932
+ ]
933
+
934
+ # ── Mount ──
935
+
936
+ ~>
937
+ requestAnimationFrame ->
938
+ echarts.registerTheme('evidence', THEME)
939
+ @initCoreCharts()
940
+ @initMaps()
941
+ @makeGauge('gauge1', 72.4, 'NPS Score', '{value}', '#236aa4', 100)
942
+ @makeGauge('gauge2', 94.2, 'CSAT', '{value}%', '#46a485', 100)
943
+ @makeGauge('gauge3', 99.8, 'Uptime', '{value}%', '#45a1bf', 100)
944
+ @initHierarchicalCharts()
945
+ @initAnalyticalCharts()
946
+ @initAdditionalCharts()
947
+
948
+ # ── Render ──
949
+
950
+ render
951
+ h1 "ACME Corp Dashboard"
952
+ p class: 'subtitle', "FY 2026 SaaS Metrics \u2014 styled with the evidence.dev ECharts theme"
953
+
954
+ .kpi-row
955
+ for kpi in kpis
956
+ KpiCard
957
+ label: kpi.label, value: kpi.value
958
+ spark: kpi.spark, delta: kpi.delta, up: kpi.up
959
+
960
+ p class: 'section', "Core Charts"
961
+ .grid
962
+ ChartCard title: 'Monthly Revenue', subtitle: 'Total recurring revenue by month', chartId: 'line'
963
+ ChartCard title: 'Revenue by Product', subtitle: 'Stacked area breakdown across product lines', chartId: 'area'
964
+ ChartCard title: 'Quarterly Revenue', subtitle: 'Quarter-over-quarter comparison', chartId: 'bar'
965
+ ChartCard title: 'Revenue by Product \u00d7 Quarter', subtitle: 'Stacked bar with product breakdown', chartId: 'stackedBar'
966
+ ChartCard title: 'Revenue & Margin', subtitle: 'Revenue bars with gross margin % line overlay', chartId: 'combo'
967
+ ChartCard title: 'Revenue & Growth Rate', subtitle: 'Dual-axis: revenue (left) and YoY growth (right)', chartId: 'multiAxis'
968
+ ChartCard title: 'Customers by Segment', subtitle: 'Distribution across customer tiers', chartId: 'pie'
969
+ ChartCard title: 'Deal Size vs. Close Rate', subtitle: 'Each bubble represents a sales rep', chartId: 'scatter'
970
+ ChartCard title: 'Feature Usage Heatmap', subtitle: 'Active sessions by day of week and hour', chartId: 'heatmap'
971
+ ChartCard title: 'Sales Pipeline', subtitle: 'Conversion through funnel stages', chartId: 'funnel'
972
+ ChartCard title: 'Customer Acquisition Flow', subtitle: 'Channel attribution from source to conversion', chartId: 'sankey', wide: true
973
+
974
+ p class: 'section', "Geographic"
975
+ .grid
976
+ ChartCard title: 'Revenue by State', subtitle: 'Annual revenue across US states', chartId: 'choropleth', tall: true
977
+ ChartCard title: 'Customer Locations', subtitle: 'Purchase origins clustered by region (k-means)', chartId: 'scatterMap', tall: true
978
+
979
+ p class: 'section', "KPI Gauges"
980
+ .grid
981
+ div class: 'card wide'
982
+ h2 "Key Performance Indicators"
983
+ p "Net Promoter Score, Customer Satisfaction, and Platform Uptime"
984
+ .gauge-row
985
+ div id: 'gauge1', class: 'gauge-cell'
986
+ div id: 'gauge2', class: 'gauge-cell'
987
+ div id: 'gauge3', class: 'gauge-cell'
988
+
989
+ p class: 'section', "Hierarchical"
990
+ .grid
991
+ ChartCard title: 'Revenue Breakdown', subtitle: 'Treemap by division, product, and feature', chartId: 'treemap'
992
+ ChartCard title: 'Revenue Composition', subtitle: 'Sunburst showing contribution at each level', chartId: 'sunburst'
993
+
994
+ p class: 'section', "Analytical"
995
+ .grid
996
+ ChartCard title: 'Product Comparison', subtitle: 'Multi-metric radar across product lines', chartId: 'radar'
997
+ ChartCard title: 'Revenue Bridge', subtitle: 'Waterfall from Q1 baseline through Q4', chartId: 'waterfall'
998
+ ChartCard title: 'Deal Size Distribution', subtitle: 'Boxplot of closed deal values by quarter ($k)', chartId: 'boxplot'
999
+ ChartCard title: 'Product Metrics', subtitle: 'Parallel coordinates across key dimensions', chartId: 'parallel'
1000
+ ChartCard title: 'Daily Platform Activity', subtitle: 'Login volume over the past year', chartId: 'calendar', wide: true
1001
+
1002
+ p class: 'section', "Additional Chart Types"
1003
+ .grid
1004
+ ChartCard title: 'Response Time Distribution', subtitle: 'Histogram of API response times (ms)', chartId: 'histogram'
1005
+ ChartCard title: 'Feature Adoption', subtitle: 'Horizontal bar chart of feature usage rates', chartId: 'horizBar'
1006
+ ChartCard title: 'Support Volume by Channel', subtitle: 'Polar area chart of ticket counts', chartId: 'polar'
1007
+ ChartCard title: 'Engagement vs. Retention', subtitle: 'Scatter with regression trend by cohort', chartId: 'scatterReg'
1008
+
1009
+ p class: 'section', "Clinical"
1010
+ .grid
1011
+ ChartCard title: 'Hemoglobin A1c Distribution' , subtitle: 'Population distribution of A1c results (green: normal, yellow: pre-diabetic, red: diabetic)', chartId: 'a1c', wide: true
1012
+ ChartCard title: 'Cholesterol by Age (\u00b11\u03c3)', subtitle: 'Average total cholesterol with standard deviation band and trend line', chartId: 'cholesterolBand', wide: true
1013
+ ChartCard title: 'Total Cholesterol by Age' , subtitle: 'Average total cholesterol vs patient age with trend line', chartId: 'cholesterol'
1014
+ </script>
1015
+
1016
+ </body>
1017
+ </html>