TouchChart
A line chart read by finger with a crosshair and value, pinched to a range, with buttons, keys and a table to the same places.
Demo
Worked by hand
D0 to D60
$100
$120
$140
$160
- Low
- $100
- High
- $160
- Change
- $60
| Point | Value |
|---|---|
| D0 | $100 |
| D1 | $101 |
| D2 | $102 |
| D3 | $103 |
| D4 | $104 |
| D5 | $105 |
| D6 | $106 |
| D7 | $107 |
| D8 | $108 |
| D9 | $109 |
| D10 | $110 |
| D11 | $111 |
| D12 | $112 |
| D13 | $113 |
| D14 | $114 |
| D15 | $115 |
| D16 | $116 |
| D17 | $117 |
| D18 | $118 |
| D19 | $119 |
| D20 | $120 |
| D21 | $121 |
| D22 | $122 |
| D23 | $123 |
| D24 | $124 |
| D25 | $125 |
| D26 | $126 |
| D27 | $127 |
| D28 | $128 |
| D29 | $129 |
| D30 | $130 |
| D31 | $131 |
| D32 | $132 |
| D33 | $133 |
| D34 | $134 |
| D35 | $135 |
| D36 | $136 |
| D37 | $137 |
| D38 | $138 |
| D39 | $139 |
| D40 | $140 |
| D41 | $141 |
| D42 | $142 |
| D43 | $143 |
| D44 | $144 |
| D45 | $145 |
| D46 | $146 |
| D47 | $147 |
| D48 | $148 |
| D49 | $149 |
| D50 | $150 |
| D51 | $151 |
| D52 | $152 |
| D53 | $153 |
| D54 | $154 |
| D55 | $155 |
| D56 | $156 |
| D57 | $157 |
| D58 | $158 |
| D59 | $159 |
| D60 | $160 |
Nothing chosen.
All.
Daily active people
1 May to 27 Oct
150
200
- Low
- 113.3 k
- High
- 210.7 k
- Change
- 89.6 k
| Point | Value |
|---|---|
| 1 May | 120 k |
| 2 May | 128.3 k |
| 3 May | 123.2 k |
| 4 May | 121.5 k |
| 5 May | 132.3 k |
| 6 May | 136.3 k |
| 7 May | 129.3 k |
| 8 May | 131.7 k |
| 9 May | 142.3 k |
| 10 May | 141.1 k |
| 11 May | 134.4 k |
| 12 May | 140.4 k |
| 13 May | 148.3 k |
| 14 May | 142.4 k |
| 15 May | 137.8 k |
| 16 May | 146.1 k |
| 17 May | 149.3 k |
| 18 May | 140.4 k |
| 19 May | 139.2 k |
| 20 May | 147.7 k |
| 21 May | 145.5 k |
| 22 May | 136 k |
| 23 May | 138.6 k |
| 24 May | 145.1 k |
| 25 May | 138.1 k |
| 26 May | 130.4 k |
| 27 May | 136.2 k |
| 28 May | 139 k |
| 29 May | 129 k |
| 30 May | 125.2 k |
| 31 May | 132.6 k |
| 1 Jun | 130.9 k |
| 2 Jun | 120.5 k |
| 3 Jun | 121.6 k |
| 4 Jun | 128.6 k |
| 5 Jun | 122.9 k |
| 6 Jun | 114.8 k |
| 7 Jun | 120.4 k |
| 8 Jun | 125.2 k |
| 9 Jun | 117 k |
| 10 Jun | 113.3 k |
| 11 Jun | 121.9 k |
| 12 Jun | 123.4 k |
| 13 Jun | 114.8 k |
| 14 Jun | 116.5 k |
| 15 Jun | 126.1 k |
| 16 Jun | 123.8 k |
| 17 Jun | 117.2 k |
| 18 Jun | 124.1 k |
| 19 Jun | 132.2 k |
| 20 Jun | 127 k |
| 21 Jun | 124.3 k |
| 22 Jun | 134.6 k |
| 23 Jun | 139.4 k |
| 24 Jun | 132.7 k |
| 25 Jun | 134.8 k |
| 26 Jun | 146.2 k |
| 27 Jun | 146.5 k |
| 28 Jun | 140.5 k |
| 29 Jun | 147.1 k |
| 30 Jun | 156.7 k |
| 1 Jul | 152.8 k |
| 2 Jul | 149.1 k |
| 3 Jul | 158.7 k |
| 4 Jul | 164.3 k |
| 5 Jul | 157.4 k |
| 6 Jul | 157.3 k |
| 7 Jul | 167.6 k |
| 8 Jul | 168 k |
| 9 Jul | 160 k |
| 10 Jul | 163.7 k |
| 11 Jul | 172.2 k |
| 12 Jul | 167.3 k |
| 13 Jul | 160.6 k |
| 14 Jul | 167.2 k |
| 15 Jul | 171.8 k |
| 16 Jul | 163 k |
| 17 Jul | 159.4 k |
| 18 Jul | 167.2 k |
| 19 Jul | 166.7 k |
| 20 Jul | 156.4 k |
| 21 Jul | 156.8 k |
| 22 Jul | 163.9 k |
| 23 Jul | 158.4 k |
| 24 Jul | 149.3 k |
| 25 Jul | 153.6 k |
| 26 Jul | 158 k |
| 27 Jul | 149 k |
| 28 Jul | 143.4 k |
| 29 Jul | 150.4 k |
| 30 Jul | 151 k |
| 31 Jul | 140.9 k |
| 1 Aug | 140.1 k |
| 2 Aug | 148.1 k |
| 3 Aug | 144.7 k |
| 4 Aug | 136 k |
| 5 Aug | 140.4 k |
| 6 Aug | 147.2 k |
| 7 Aug | 140.7 k |
| 8 Aug | 135.8 k |
| 9 Aug | 144.2 k |
| 10 Aug | 148.2 k |
| 11 Aug | 140.4 k |
| 12 Aug | 140.6 k |
| 13 Aug | 150.9 k |
| 14 Aug | 151.1 k |
| 15 Aug | 144.2 k |
| 16 Aug | 149.7 k |
| 17 Aug | 159.4 k |
| 18 Aug | 156 k |
| 19 Aug | 151.9 k |
| 20 Aug | 161.5 k |
| 21 Aug | 168.3 k |
| 22 Aug | 162.3 k |
| 23 Aug | 162.4 k |
| 24 Aug | 173.7 k |
| 25 Aug | 176.1 k |
| 26 Aug | 169.4 k |
| 27 Aug | 173.9 k |
| 28 Aug | 184.2 k |
| 29 Aug | 181.7 k |
| 30 Aug | 176.3 k |
| 31 Aug | 184.2 k |
| 1 Sep | 191.1 k |
| 2 Sep | 184.6 k |
| 3 Sep | 182 k |
| 4 Sep | 191.5 k |
| 5 Sep | 193.4 k |
| 6 Sep | 184.7 k |
| 7 Sep | 185.8 k |
| 8 Sep | 194.5 k |
| 9 Sep | 190.9 k |
| 10 Sep | 182.5 k |
| 11 Sep | 187.1 k |
| 12 Sep | 192.7 k |
| 13 Sep | 184.7 k |
| 14 Sep | 178.8 k |
| 15 Sep | 185.7 k |
| 16 Sep | 186.9 k |
| 17 Sep | 176.5 k |
| 18 Sep | 174.6 k |
| 19 Sep | 182 k |
| 20 Sep | 178.4 k |
| 21 Sep | 168.4 k |
| 22 Sep | 171 k |
| 23 Sep | 176.9 k |
| 24 Sep | 169.5 k |
| 25 Sep | 162.3 k |
| 26 Sep | 168.7 k |
| 27 Sep | 171.6 k |
| 28 Sep | 162.2 k |
| 29 Sep | 159.9 k |
| 30 Sep | 168.4 k |
| 1 Oct | 167.5 k |
| 2 Oct | 158.7 k |
| 3 Oct | 161.7 k |
| 4 Oct | 170.2 k |
| 5 Oct | 165.9 k |
| 6 Oct | 159.9 k |
| 7 Oct | 167.7 k |
| 8 Oct | 174 k |
| 9 Oct | 167.4 k |
| 10 Oct | 166 k |
| 11 Oct | 176.7 k |
| 12 Oct | 179.3 k |
| 13 Oct | 172.4 k |
| 14 Oct | 176.2 k |
| 15 Oct | 187.1 k |
| 16 Oct | 185.6 k |
| 17 Oct | 180.4 k |
| 18 Oct | 188.5 k |
| 19 Oct | 197.1 k |
| 20 Oct | 192.1 k |
| 21 Oct | 190.1 k |
| 22 Oct | 200.8 k |
| 23 Oct | 205 k |
| 24 Oct | 198 k |
| 25 Oct | 200.1 k |
| 26 Oct | 210.7 k |
| 27 Oct | 209.6 k |
With holes
1 to 8
6
8
- Low
- 5
- High
- 9
- Change
- 4
| Point | Value |
|---|---|
| 1 | 5 |
| 2 | 6 |
| 3 | no value |
| 4 | no value |
| 5 | 7 |
| 6 | no value |
| 7 | 8 |
| 8 | 9 |
Five thousand points and a spike
1 to 5000
0
50
100
- Low
- 0
- High
- 100
- Change
- 1
| Point | Value |
|---|---|
| 1 | 0 |
| 2 | 1 |
| 3 | 0 |
| 4 | 1 |
| 5 | 0 |
| 6 | 1 |
| 7 | 0 |
| 8 | 1 |
| 9 | 0 |
| 10 | 1 |
| 11 | 0 |
| 12 | 1 |
| 13 | 0 |
| 14 | 1 |
| 15 | 0 |
| 16 | 1 |
| 17 | 0 |
| 18 | 1 |
| 19 | 0 |
| 20 | 1 |
| 21 | 0 |
| 22 | 1 |
| 23 | 0 |
| 24 | 1 |
| 25 | 0 |
| 26 | 1 |
| 27 | 0 |
| 28 | 1 |
| 29 | 0 |
| 30 | 1 |
| 31 | 0 |
| 32 | 1 |
| 33 | 0 |
| 34 | 1 |
| 35 | 0 |
| 36 | 1 |
| 37 | 0 |
| 38 | 1 |
| 39 | 0 |
| 40 | 1 |
| 41 | 0 |
| 42 | 1 |
| 43 | 0 |
| 44 | 1 |
| 45 | 0 |
| 46 | 1 |
| 47 | 0 |
| 48 | 1 |
| 49 | 0 |
| 50 | 1 |
| 51 | 0 |
| 52 | 1 |
| 53 | 0 |
| 54 | 1 |
| 55 | 0 |
| 56 | 1 |
| 57 | 0 |
| 58 | 1 |
| 59 | 0 |
| 60 | 1 |
| 61 | 0 |
| 62 | 1 |
| 63 | 0 |
| 64 | 1 |
| 65 | 0 |
| 66 | 1 |
| 67 | 0 |
| 68 | 1 |
| 69 | 0 |
| 70 | 1 |
| 71 | 0 |
| 72 | 1 |
| 73 | 0 |
| 74 | 1 |
| 75 | 0 |
| 76 | 1 |
| 77 | 0 |
| 78 | 1 |
| 79 | 0 |
| 80 | 1 |
| 81 | 0 |
| 82 | 1 |
| 83 | 0 |
| 84 | 1 |
| 85 | 0 |
| 86 | 1 |
| 87 | 0 |
| 88 | 1 |
| 89 | 0 |
| 90 | 1 |
| 91 | 0 |
| 92 | 1 |
| 93 | 0 |
| 94 | 1 |
| 95 | 0 |
| 96 | 1 |
| 97 | 0 |
| 98 | 1 |
| 99 | 0 |
| 100 | 1 |
| 101 | 0 |
| 102 | 1 |
| 103 | 0 |
| 104 | 1 |
| 105 | 0 |
| 106 | 1 |
| 107 | 0 |
| 108 | 1 |
| 109 | 0 |
| 110 | 1 |
| 111 | 0 |
| 112 | 1 |
| 113 | 0 |
| 114 | 1 |
| 115 | 0 |
| 116 | 1 |
| 117 | 0 |
| 118 | 1 |
| 119 | 0 |
| 120 | 1 |
| 121 | 0 |
| 122 | 1 |
| 123 | 0 |
| 124 | 1 |
| 125 | 0 |
| 126 | 1 |
| 127 | 0 |
| 128 | 1 |
| 129 | 0 |
| 130 | 1 |
| 131 | 0 |
| 132 | 1 |
| 133 | 0 |
| 134 | 1 |
| 135 | 0 |
| 136 | 1 |
| 137 | 0 |
| 138 | 1 |
| 139 | 0 |
| 140 | 1 |
| 141 | 0 |
| 142 | 1 |
| 143 | 0 |
| 144 | 1 |
| 145 | 0 |
| 146 | 1 |
| 147 | 0 |
| 148 | 1 |
| 149 | 0 |
| 150 | 1 |
| 151 | 0 |
| 152 | 1 |
| 153 | 0 |
| 154 | 1 |
| 155 | 0 |
| 156 | 1 |
| 157 | 0 |
| 158 | 1 |
| 159 | 0 |
| 160 | 1 |
| 161 | 0 |
| 162 | 1 |
| 163 | 0 |
| 164 | 1 |
| 165 | 0 |
| 166 | 1 |
| 167 | 0 |
| 168 | 1 |
| 169 | 0 |
| 170 | 1 |
| 171 | 0 |
| 172 | 1 |
| 173 | 0 |
| 174 | 1 |
| 175 | 0 |
| 176 | 1 |
| 177 | 0 |
| 178 | 1 |
| 179 | 0 |
| 180 | 1 |
| 181 | 0 |
| 182 | 1 |
| 183 | 0 |
| 184 | 1 |
| 185 | 0 |
| 186 | 1 |
| 187 | 0 |
| 188 | 1 |
| 189 | 0 |
| 190 | 1 |
| 191 | 0 |
| 192 | 1 |
| 193 | 0 |
| 194 | 1 |
| 195 | 0 |
| 196 | 1 |
| 197 | 0 |
| 198 | 1 |
| 199 | 0 |
| 200 | 1 |
| 201 | 0 |
| 202 | 1 |
| 203 | 0 |
| 204 | 1 |
| 205 | 0 |
| 206 | 1 |
| 207 | 0 |
| 208 | 1 |
| 209 | 0 |
| 210 | 1 |
| 211 | 0 |
| 212 | 1 |
| 213 | 0 |
| 214 | 1 |
| 215 | 0 |
| 216 | 1 |
| 217 | 0 |
| 218 | 1 |
| 219 | 0 |
| 220 | 1 |
| 221 | 0 |
| 222 | 1 |
| 223 | 0 |
| 224 | 1 |
| 225 | 0 |
| 226 | 1 |
| 227 | 0 |
| 228 | 1 |
| 229 | 0 |
| 230 | 1 |
| 231 | 0 |
| 232 | 1 |
| 233 | 0 |
| 234 | 1 |
| 235 | 0 |
| 236 | 1 |
| 237 | 0 |
| 238 | 1 |
| 239 | 0 |
| 240 | 1 |
| 241 | 0 |
| 242 | 1 |
| 243 | 0 |
| 244 | 1 |
| 245 | 0 |
| 246 | 1 |
| 247 | 0 |
| 248 | 1 |
| 249 | 0 |
| 250 | 1 |
| 251 | 0 |
| 252 | 1 |
| 253 | 0 |
| 254 | 1 |
| 255 | 0 |
| 256 | 1 |
| 257 | 0 |
| 258 | 1 |
| 259 | 0 |
| 260 | 1 |
| 261 | 0 |
| 262 | 1 |
| 263 | 0 |
| 264 | 1 |
| 265 | 0 |
| 266 | 1 |
| 267 | 0 |
| 268 | 1 |
| 269 | 0 |
| 270 | 1 |
| 271 | 0 |
| 272 | 1 |
| 273 | 0 |
| 274 | 1 |
| 275 | 0 |
| 276 | 1 |
| 277 | 0 |
| 278 | 1 |
| 279 | 0 |
| 280 | 1 |
| 281 | 0 |
| 282 | 1 |
| 283 | 0 |
| 284 | 1 |
| 285 | 0 |
| 286 | 1 |
| 287 | 0 |
| 288 | 1 |
| 289 | 0 |
| 290 | 1 |
| 291 | 0 |
| 292 | 1 |
| 293 | 0 |
| 294 | 1 |
| 295 | 0 |
| 296 | 1 |
| 297 | 0 |
| 298 | 1 |
| 299 | 0 |
| 300 | 1 |
| 301 | 0 |
| 302 | 1 |
| 303 | 0 |
| 304 | 1 |
| 305 | 0 |
| 306 | 1 |
| 307 | 0 |
| 308 | 1 |
| 309 | 0 |
| 310 | 1 |
| 311 | 0 |
| 312 | 1 |
| 313 | 0 |
| 314 | 1 |
| 315 | 0 |
| 316 | 1 |
| 317 | 0 |
| 318 | 1 |
| 319 | 0 |
| 320 | 1 |
| 321 | 0 |
| 322 | 1 |
| 323 | 0 |
| 324 | 1 |
| 325 | 0 |
| 326 | 1 |
| 327 | 0 |
| 328 | 1 |
| 329 | 0 |
| 330 | 1 |
| 331 | 0 |
| 332 | 1 |
| 333 | 0 |
| 334 | 1 |
| 335 | 0 |
| 336 | 1 |
| 337 | 0 |
| 338 | 1 |
| 339 | 0 |
| 340 | 1 |
| 341 | 0 |
| 342 | 1 |
| 343 | 0 |
| 344 | 1 |
| 345 | 0 |
| 346 | 1 |
| 347 | 0 |
| 348 | 1 |
| 349 | 0 |
| 350 | 1 |
| 351 | 0 |
| 352 | 1 |
| 353 | 0 |
| 354 | 1 |
| 355 | 0 |
| 356 | 1 |
| 357 | 0 |
| 358 | 1 |
| 359 | 0 |
| 360 | 1 |
| 361 | 0 |
| 362 | 1 |
| 363 | 0 |
| 364 | 1 |
| 365 | 0 |
| 366 | 1 |
| 367 | 0 |
| 368 | 1 |
| 369 | 0 |
| 370 | 1 |
| 371 | 0 |
| 372 | 1 |
| 373 | 0 |
| 374 | 1 |
| 375 | 0 |
| 376 | 1 |
| 377 | 0 |
| 378 | 1 |
| 379 | 0 |
| 380 | 1 |
| 381 | 0 |
| 382 | 1 |
| 383 | 0 |
| 384 | 1 |
| 385 | 0 |
| 386 | 1 |
| 387 | 0 |
| 388 | 1 |
| 389 | 0 |
| 390 | 1 |
| 391 | 0 |
| 392 | 1 |
| 393 | 0 |
| 394 | 1 |
| 395 | 0 |
| 396 | 1 |
| 397 | 0 |
| 398 | 1 |
| 399 | 0 |
| 400 | 1 |
Nothing
Nothing to show yet.
| Point | Value |
|---|
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/touch_chart.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/touch_chart.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/touch_chart.qwe
// Sixty-one points that rise by one, so every reading below can be worked out by hand.let touch_chart_demo_exact = for i in range(0, 61) => { label: `D${i}`, value: 100 + i }let touch_chart_demo_year = for i in range(0, 180) => { label: date.format(date.add_days("2026-05-01", i), "D MMM"), value: round(120 + i * 0.4 + 18 * math.sin(i / 9) + 6 * math.sin(i * 1.7), 1),}let touch_chart_demo_long = for i in range(0, 5000) => i == 2345 ? 100 : i % 2component TouchChartDemoLive() { state said = "Nothing chosen." state range_said = "All." state stir = 0 <div.demo-stack> <div id="touch-chart-exact"><TouchChart title="Worked by hand" points={touch_chart_demo_exact | map((p) => { ...p, stir })} prefix="$" digits={2} ranges={[{ label: "Last 10", count: 10 }, { label: "All", count: 0 }]} on_select={(i) => { said = i == null ? "Cleared." : `${i.index} ${i.label} ${i.value}` }} on_range={(a, b) => { range_said = `${a} to ${b}` }}/></div> <p.demo-note id="touch-chart-said">{said}</p> <p.demo-note id="touch-chart-range">{range_said}</p> <div.demo-row><Button id="touch-chart-stir" size="lg" variant="soft" on_click={() => { stir += 1 }}>New data arrives</Button></div> </div>}view TouchChartDemo() = <div.demo-stack> <TouchChartDemoLive/> <div id="touch-chart-season"><TouchChart title="Daily active people" unit="k" points={touch_chart_demo_year} ranges={[{ label: "30 days", count: 30 }, { label: "90 days", count: 90 }, { label: "All", count: 0 }]}/></div> <div id="touch-chart-holes"><TouchChart title="With holes" points={[5, 6, "broken", null, 7, null, 8, 9]} least={2}/></div> <div id="touch-chart-long"><TouchChart title="Five thousand points and a spike" points={touch_chart_demo_long}/></div> <div id="touch-chart-none"><TouchChart title="Nothing" points="none"/></div> </div>Source
ui/touch_chart.qwe
// TouchChart: a line chart for a thumb. A finger along it stops on any point, with a// crosshair and the value in a label that keeps clear of the edges, and the page still// scrolls up and down under a passing finger. Two fingers pinch to a range and slide it, and// the scale fits what is in view. Range buttons, zoom buttons and keys do the same, and a// table holds what is in view for screen readers.//// <TouchChart title="Daily active people" unit="k" points={[// { label: "1 Mar", value: 41.2 },// { label: "2 Mar", value: 43.8 },// ]} ranges={[{ label: "7 days", count: 7 }, { label: "30 days", count: 30 }, { label: "All", count: 0 }]}/>//// points: each { label, value }, or a plain number. A value that is not a number is a hole,// over which the line lifts. ranges: buttons, each { label, count }, showing the last count// points, 0 being all of them. least: the fewest points a pinch comes down to (5). prefix,// unit: around a value ("$", "k"), digits: decimals shown (1). title. on_select(info): inside// one of your components, when a finger lifts or a key moves, with { index, label, value }, or// null on clearing. on_range(from, to): when the range changes, with the first and last// index in view. class.//// Keys, on the focused chart: left and right move from point to point, Home and End go to// the ends of the range, + and - zoom, Page Up and Down slide the range, Escape clears.// An upright pinch reads as a scroll on some phones, as touch-action keeps the page's// up and down: a pinch across the chart is the one that always works.import ui/theme.qwe use ui_clslet touch_chart_css = css`.ui-tchart { position: relative; container-type: inline-size; color: var(--ui-fg); font: 15px/1.4 var(--ui-font); }.ui-tchart-title { margin: 0; font-size: 13px; font-weight: 600; color: var(--ui-muted); }.ui-tchart-span { margin: 0 0 8px; color: var(--ui-muted); font-size: 14px; font-variant-numeric: tabular-nums; }.ui-tchart-body { display: grid; gap: 10px; }.ui-tchart-main { min-width: 0; }.ui-tchart-plot { position: relative; height: 240px; overflow: hidden; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); outline: none; cursor: crosshair; touch-action: pan-y; overscroll-behavior-x: contain; -webkit-tap-highlight-color: transparent; }.ui-tchart-plot:focus-visible { box-shadow: var(--ui-focus); }.ui-tchart-busy { user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }.ui-tchart-plot svg { position: absolute; inset: 0; display: block; width: 100%; height: 100%; transform-origin: 0 0; }.ui-tchart-line { fill: none; stroke: var(--ui-accent); stroke-width: 2.5; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; }.ui-tchart-grid { position: absolute; left: 0; right: 0; height: 0; border-top: 1px solid var(--ui-border); pointer-events: none; }.ui-tchart-grid span { position: absolute; left: 4px; top: 1px; color: var(--ui-muted); font-size: 11px; font-variant-numeric: tabular-nums; }/* As wide as the plot, the line at its left edge, so a percentage of its own width is a place on the plot at any size. */.ui-tchart-cross { position: absolute; inset: 0; pointer-events: none; }.ui-tchart-cross::before { content: ""; position: absolute; inset: 0 auto 0 -1px; width: 2px; background: var(--ui-fg); }.ui-tchart-pt { position: absolute; left: -7px; width: 14px; height: 14px; margin-top: -7px; border: 3px solid var(--ui-surface); border-radius: 50%; background: var(--ui-accent); }/* left: X% with translateX(-X%) keeps the label inside the plot at both ends, at any size. */.ui-tchart-tip { position: absolute; top: 6px; max-width: 70%; padding: 4px 8px; border-radius: var(--ui-radius-sm); background: var(--ui-fg); color: var(--ui-bg); font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; pointer-events: none; overflow: hidden; text-overflow: ellipsis; }.ui-tchart-pinching .ui-tchart-cross, .ui-tchart-pinching .ui-tchart-tip { visibility: hidden; }.ui-tchart-axis { display: flex; justify-content: space-between; gap: 8px; margin-top: 2px; color: var(--ui-muted); font-size: 12px; font-variant-numeric: tabular-nums; }.ui-tchart-keys { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }.ui-tchart-key { flex: 1 1 auto; min-width: 44px; min-height: 44px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 500 14px/1 var(--ui-font); cursor: pointer; touch-action: manipulation; white-space: nowrap; }.ui-tchart-key[aria-pressed="true"] { background: var(--ui-accent-soft); border-color: var(--ui-accent); color: var(--ui-accent); }.ui-tchart-key:disabled { opacity: .45; cursor: default; }.ui-tchart-key:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-tchart-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 0; }.ui-tchart-stats div { min-width: 0; }.ui-tchart-stats dt { color: var(--ui-muted); font-size: 12px; }.ui-tchart-stats dd { margin: 0; font-weight: 600; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }.ui-tchart-none { margin: 0; color: var(--ui-muted); }@container (min-width: 620px) { .ui-tchart-body { grid-template-columns: minmax(0, 1fr) 170px; gap: 24px; } .ui-tchart-plot { height: 340px; } .ui-tchart-stats { grid-template-columns: minmax(0, 1fr); align-content: start; gap: 18px; } .ui-tchart-stats dd { font-size: 20px; }}@media (hover: hover) { .ui-tchart-key:not(:disabled):hover { background: var(--ui-subtle); } }`// The points to draw, at most 20000: a value is a real number or null, a label always text.fn touch_chart_clean(points) { if type_of(points) != "list" { return [] } for [i, p] in enumerate(take(points, 20000)) => { let m = type_of(p) == "map" let v = m ? get(p, "value") : p let t = type_of(v) let ok = (t == "int" || t == "float" || t == "dec") && float(v) == float(v) && abs(float(v)) < 1000000000000000 { i, label: m && get(p, "label") != null ? str(p.label) : `${i + 1}`, value: ok ? float(v) : null } }}fn touch_chart_show(v, prefix: str, unit: str, digits: int) -> str { if v == null { return "no value" } let r = round(abs(v), digits) let text = r == floor(r) ? str(floor(r)) : str(r) `${v < 0 ? "-" : ""}${prefix}${text}${unit != "" ? " " + unit : ""}`}// The range a pinch comes to. scale is how much further apart the fingers are, share where// they began across the chart (0 to 1), and shift how far they slid, in widths: what was under// the fingers stays under them, and it stays inside the data and no closer than least.fn touch_chart_zoom(a0: num, b0: num, n: int, scale: num, share: num, shift: num, least: int) { let top = n - 1 let floor_span = least < 1 ? 1 : (least > top ? top : least) let span0 = b0 - a0 let wanted = span0 / (scale < 0.01 ? 0.01 : scale) let span = wanted < floor_span ? floor_span : (wanted > top ? top : wanted) let start = a0 + share * span0 - (share + shift) * span let a = start < 0 ? 0.0 : (start > top - span ? top - span : start) { from: a, to: a + span }}// Whole points, for what is kept once the fingers lift.fn touch_chart_whole(r) { let a = round(r.from) { from: a, to: a + round(r.to - r.from) }}// The last count points, or all.fn touch_chart_last(n: int, count: int) { if count <= 0 || count >= n { return { from: 0, to: n - 1 } } { from: n - count, to: n - 1 }}// The scale for what is in view: a little room around it, and something to show when flat.fn touch_chart_scale(vals) { if len(vals) == 0 { return { lo: 0, hi: 1 } } let a = min(vals) let b = max(vals) let wide = b - a let room = wide > 0 ? wide * 0.1 : (a != 0 ? abs(a) * 0.1 : 1) { lo: a - room, hi: b + room }}fn touch_chart_y(v: num, lo: num, hi: num) -> float = round((hi - v) * 300.0 / (hi - lo), 2)fn touch_chart_x(i: num, a: num, b: num) -> float = b > a ? round((i - a) * 1000.0 / (b - a), 2) : 500.0// A long range has more points than the plot has pixels: each stretch of it gives only// its lowest and highest, so a spike is never lost.fn touch_chart_thin(rows) { let n = len(rows) if n <= 600 { return rows } let each = ceil(n / 300) let chunks = for k in range(0, ceil(n / each)) => slice(rows, k * each, (k + 1) * each) flatten(for c in chunks => { let vals = c | filter((r) => r.value != null) if len(vals) == 0 { [c[0]] } else { let hi = reduce(vals, vals[0], (m, r) => r.value > m.value ? r : m) let lo = reduce(vals, vals[0], (m, r) => r.value < m.value ? r : m) lo.i == hi.i ? [lo] : (lo.i < hi.i ? [lo, hi] : [hi, lo]) } })}// The line, lifted over a hole. A lone point between two holes gets a dot, or it would not show.fn touch_chart_path(rows, a: num, b: num, lo: num, hi: num) -> str { let n = len(rows) let parts = for [k, r] in enumerate(rows) => { if r.value == null { "" } else { let start = k == 0 || rows[k - 1].value == null let alone = start && (k == n - 1 || rows[k + 1].value == null) `${start ? "M" : "L"}${touch_chart_x(r.i, a, b)} ${touch_chart_y(r.value, lo, hi)}${alone ? "h.01" : ""}` } } join(parts, "")}// Lines at round numbers.fn touch_chart_ticks(lo: num, hi: num) { let wide = hi - lo if wide <= 0 { return [] } let rough = wide / 4 let power = pow(10, floor(math.log10(rough))) let unit = rough / power let step = (unit <= 1 ? 1 : (unit <= 2 ? 2 : (unit <= 5 ? 5 : 10))) * power let first = ceil(lo / step) let n = floor(hi / step) - first + 1 for i in range(0, n > 8 ? 8 : (n < 0 ? 0 : n)) => round((first + i) * step, 6)}// The nearest point to a share of the chart's width.fn touch_chart_at(share: num, a: int, b: int) -> int { let s = share < 0 ? 0 : (share > 1 ? 1 : share) a + round(s * (b - a))}// Low, high and how far it moved, in what is in view.fn touch_chart_stats(rows) { let vals = rows | filter((r) => r.value != null) | map((r) => r.value) if len(vals) == 0 { return null } { low: min(vals), high: max(vals), change: last(vals) - first(vals) }}component TouchChart(points = [], title: str? = null, ranges = null, least: int = 5, prefix: str = "", unit: str = "", digits: int = 1, on_select = null, on_range = null, class: str? = null) { style global touch_chart_css let rows = touch_chart_clean(points) let n = len(rows) let chips = type_of(ranges) == "list" ? ranges | filter((r) => type_of(r) == "map" && filled(get(r, "label")) != null) : [] state from = 0 state to = n - 1 state pick = null // the point chosen state busy = false state pinching = false state g = null // one finger state pin = null // two fingers: { a, b, x0, y0, x1, y1, ... } state plot = null state cross = null state point = null state tip = null state svg = null state span_el = null let at_max = from == 0 && to == n - 1 let view_rows = n > 0 ? slice(rows, from, to + 1) : [] let vals = view_rows | filter((r) => r.value != null) | map((r) => r.value) let sc = touch_chart_scale(vals) let stats = touch_chart_stats(view_rows) let say = (r) => `${r.label}: ${touch_chart_show(r.value, prefix, unit, digits)}` let range_text = (a: int, b: int) => n > 0 ? `${rows[a].label} to ${rows[b].label}` : "" // By hand, as these change with every move of a finger. let show_it = (i) => { if cross != null && i != null { let r = rows[i] let pct = touch_chart_x(i, from, to) / 10 cross.style.transform = `translateX(${pct}%)` point.hidden = r.value == null if r.value != null { point.style.top = `${touch_chart_y(r.value, sc.lo, sc.hi) / 3}%` } tip.style.left = `${pct}%` tip.style.transform = `translateX(${0 - pct}%)` tip.textContent = say(r) } } let share_of = (x: num) => { let box = plot.getBoundingClientRect() (x - box.left) / box.width } let choose = (i) => { pick = i if on_select != null { on_select(i == null ? null : { index: i, label: rows[i].label, value: rows[i].value }) } } let settle = (a: int, b: int) => { svg.style.transform = "" span_el.textContent = range_text(a, b) if a != from || b != to { from = a to = b if pick != null && (pick < a || pick > b) { pick = null } on_range && on_range(a, b) } } let zoom_by = (scale: num, shift: num) => { let middle = pick != null ? (pick - from) / (to - from) : 0.5 let r = touch_chart_whole(touch_chart_zoom(from, to, n, scale, middle, shift, least)) settle(r.from, r.to) } let pin_range = () => { let dist = sqrt(pow(pin.x1 - pin.x0, 2) + pow(pin.y1 - pin.y0, 2)) let mid = (pin.x0 + pin.x1) / 2 let width = plot.getBoundingClientRect().width touch_chart_zoom(pin.a0, pin.b0, n, dist / pin.dist, pin.share, (mid - pin.mid) / width, least) } // Follows the choice, the range and new data, but not under a finger, which has the line for now. if cross != null && pick != null && from >= 0 && to >= 0 && !busy { show_it(pick) } <section class={ui_cls(["ui-tchart", class])} aria-label={title ?? "Chart"}> {filled(title) != null && <p.ui-tchart-title>{title}</p>} {n > 0 && <p.ui-tchart-span @mount={(me) => { span_el = me }}>{range_text(from, to)}</p>} {n == 0 && <p.ui-tchart-none>Nothing to show yet.</p>} {n > 0 && <div.ui-tchart-body> <div.ui-tchart-main> <div class={ui_cls(["ui-tchart-plot", busy && "ui-tchart-busy", pinching && "ui-tchart-pinching"])} tabindex="0" role="slider" aria-label={`${title ?? "Chart"}: a point`} aria-valuemin={from + 1} aria-valuemax={to + 1} aria-valuenow={pick != null ? pick + 1 : from + 1} aria-valuetext={pick != null ? say(rows[pick]) : "Nothing chosen. Use the arrow keys."} @mount={(me) => { plot = me }} @contextmenu={(e) => { if busy { e.preventDefault() } }} @pointerdown={(e) => { if pin == null && g != null && e.pointerId != g.id && n > 2 { // The second finger: a pinch, and whatever the first was reading is put back. plot.setPointerCapture(g.id) plot.setPointerCapture(e.pointerId) pin = { id0: g.id, id1: e.pointerId, x0: g.lx, y0: g.ly, x1: e.clientX, y1: e.clientY, a0: from, b0: to, dist: 1, mid: 0, share: 0 } let dist = sqrt(pow(pin.x1 - pin.x0, 2) + pow(pin.y1 - pin.y0, 2)) let mid = (pin.x0 + pin.x1) / 2 pin = { ...pin, dist: dist < 1 ? 1 : dist, mid, share: share_of(mid) } g = null busy = true pinching = true cross.hidden = pick == null tip.hidden = pick == null show_it(pick) } else if pin == null && g == null && e.isPrimary && e.button == 0 { g = { id: e.pointerId, x: e.clientX, y: e.clientY, lx: e.clientX, ly: e.clientY, on: false } } }} @pointermove={(e) => { if pin != null { if e.pointerId == pin.id0 { pin = { ...pin, x0: e.clientX, y0: e.clientY } } if e.pointerId == pin.id1 { pin = { ...pin, x1: e.clientX, y1: e.clientY } } if e.pointerId == pin.id0 || e.pointerId == pin.id1 { let r = pin_range() let sx = (pin.b0 - pin.a0) / (r.to - r.from) let tx = (pin.a0 - r.from) * 100 * sx / (pin.b0 - pin.a0) // What the fingers do shows at once on what is drawn; the true scale comes on lifting. svg.style.transform = `translateX(${tx}%) scaleX(${sx})` span_el.textContent = range_text(round(r.from), round(r.to)) } } else if g != null && e.pointerId == g.id { g = { ...g, lx: e.clientX, ly: e.clientY } if !g.on { let dx = abs(e.clientX - g.x) let dy = abs(e.clientY - g.y) // Up and down is the page's. if dy > 6 && dy > dx { g = null } if g != null && dx > 6 && dx >= dy { plot.setPointerCapture(e.pointerId) busy = true g = { ...g, on: true } } } if g != null && g.on { cross.hidden = false tip.hidden = false show_it(touch_chart_at(share_of(e.clientX), from, to)) } } }} @pointerup={(e) => { if pin != null && (e.pointerId == pin.id0 || e.pointerId == pin.id1) { let r = touch_chart_whole(pin_range()) pin = null busy = false pinching = false settle(r.from, r.to) } else if g != null && e.pointerId == g.id { let x = e.clientX g = null busy = false choose(touch_chart_at(share_of(x), from, to)) } }} @pointercancel={(e) => { if pin != null && (e.pointerId == pin.id0 || e.pointerId == pin.id1) { pin = null busy = false pinching = false settle(from, to) } else if g != null && e.pointerId == g.id { g = null busy = false cross.hidden = pick == null tip.hidden = pick == null show_it(pick) } }} @keydown={(e) => { let k = e.key if k == "Escape" && pick != null { choose(null) } let i = pick ?? from - 1 let to_i = match k { "ArrowRight" => i + 1 "ArrowLeft" => pick == null ? to : i - 1 "Home" => from "End" => to _ => null } if to_i != null { e.preventDefault() choose(to_i < from ? from : (to_i > to ? to : to_i)) } if k == "+" || k == "=" { e.preventDefault(); zoom_by(2, 0) } if k == "-" { e.preventDefault(); zoom_by(0.5, 0) } if k == "PageDown" { e.preventDefault(); zoom_by(1, 0 - 0.5) } if k == "PageUp" { e.preventDefault(); zoom_by(1, 0.5) } }}> {for t in touch_chart_ticks(sc.lo, sc.hi) => <div.ui-tchart-grid key={t} style={`top: ${touch_chart_y(t, sc.lo, sc.hi) / 3}%`}><span>{touch_chart_show(t, prefix, "", digits)}</span></div>} <svg viewBox="0 0 1000 300" preserveAspectRatio="none" aria-hidden="true" @mount={(me) => { svg = me }}> <path.ui-tchart-line d={touch_chart_path(touch_chart_thin(view_rows), from, to, sc.lo, sc.hi)}/> </svg> <div.ui-tchart-cross hidden={pick == null} @mount={(me) => { cross = me }}> <i.ui-tchart-pt @mount={(me) => { point = me }}></i> </div> <div.ui-tchart-tip hidden={pick == null} aria-hidden="true" @mount={(me) => { tip = me }}></div> </div> <div.ui-tchart-axis aria-hidden="true"><span>{rows[from].label}</span><span>{rows[to].label}</span></div> <div.ui-tchart-keys role="group" aria-label="Range"> {for [i, c] in enumerate(chips) => <button.ui-tchart-key key={i} type="button" aria-pressed={touch_chart_last(n, get(c, "count", 0)).from == from && to == n - 1 ? "true" : "false"} @click={() => { let r = touch_chart_last(n, get(c, "count", 0)) settle(r.from, r.to) }}>{c.label}</button>} <button.ui-tchart-key.ui-tchart-in type="button" aria-label="Zoom in" disabled={to - from <= least} @click={() => { zoom_by(2, 0) }}>+</button> <button.ui-tchart-key.ui-tchart-out type="button" aria-label="Zoom out" disabled={at_max} @click={() => { zoom_by(0.5, 0) }}>-</button> <button.ui-tchart-key.ui-tchart-back type="button" aria-label="Earlier" disabled={from == 0} @click={() => { zoom_by(1, 0.5) }}>Earlier</button> <button.ui-tchart-key.ui-tchart-on type="button" aria-label="Later" disabled={to == n - 1} @click={() => { zoom_by(1, 0 - 0.5) }}>Later</button> </div> </div> <dl.ui-tchart-stats> <div><dt>Low</dt><dd>{stats != null ? touch_chart_show(stats.low, prefix, unit, digits) : "None"}</dd></div> <div><dt>High</dt><dd>{stats != null ? touch_chart_show(stats.high, prefix, unit, digits) : "None"}</dd></div> <div><dt>Change</dt><dd>{stats != null ? touch_chart_show(stats.change, prefix, unit, digits) : "None"}</dd></div> </dl> </div>} <div.ui-sr><table> <caption>{title ?? "Chart"}, {n > 0 ? range_text(from, to) : "no data"}{len(view_rows) > 400 ? ", the first 400 points" : ""}</caption> <thead><tr><th scope="col">Point</th><th scope="col">Value</th></tr></thead> <tbody>{for r in take(view_rows, 400) => <tr key={r.i}><td>{r.label}</td><td>{touch_chart_show(r.value, prefix, unit, digits)}</td></tr>}</tbody> </table></div> </section>}Related
PinchZoomImagePinch and double tap to zoom an image, pan to its edges, with a rubber band at the limits and zoom buttons as well.2 views · 7 downloadsSleepChartStages through a night as a chart read by finger, with totals, right across midnight and a change of the clocks.4 views · 8 downloadsWeightTrendA smoothed trend over noisy weigh-ins, in kg or lb, with a goal line, read by finger, and holes left as holes.3 views · 5 downloadsActionSheetA list of actions rising from the bottom, with a destructive style and Cancel: swipe down or the phone's back gesture closes it; on a tablet, a card by what opened it.3 views · 9 downloadsAlertInboxAlerts by severity, swiped to acknowledge or snooze, with buttons and keys to the same, and a badge that stays right as snoozes run out.3 views · 8 downloadsAlphaRailListA long list with sticky letter headers and an A to Z rail scrubbed by finger, with a bubble showing the letter and a haptic per step.1 view · 6 downloads
Thread
Thread 0 posts
On TouchChart: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.