|
15 | 15 |
|
16 | 16 | const peakCount = monthly.reduce((m, d) => Math.max(m, d.count), 0) || 1; |
17 | 17 |
|
| 18 | + function niceStep(peak, targetTicks = 4) { |
| 19 | + const rough = peak / targetTicks; |
| 20 | + const magnitude = Math.pow(10, Math.floor(Math.log10(rough))); |
| 21 | + const norm = rough / magnitude; |
| 22 | + let step; |
| 23 | + if (norm < 1.5) step = magnitude; |
| 24 | + else if (norm < 3) step = 2 * magnitude; |
| 25 | + else if (norm < 7) step = 5 * magnitude; |
| 26 | + else step = 10 * magnitude; |
| 27 | + return step; |
| 28 | + } |
| 29 | +
|
| 30 | + const yStep = niceStep(peakCount); |
| 31 | + const axisMax = Math.ceil(peakCount / yStep) * yStep; |
| 32 | + const yTicks = []; |
| 33 | + for (let v = yStep; v < axisMax; v += yStep) yTicks.push(v); |
| 34 | +
|
18 | 35 | function monthLabel(yyyymm) { |
19 | 36 | const [y, m] = yyyymm.split("-"); |
20 | 37 | const d = new Date(Number(y), Number(m) - 1, 1); |
|
25 | 42 | ? Array.from( |
26 | 43 | new Set([ |
27 | 44 | 0, |
28 | | - Math.floor(monthly.length * 0.25), |
29 | | - Math.floor(monthly.length * 0.5), |
30 | | - Math.floor(monthly.length * 0.75), |
31 | | - monthly.length - 1 |
| 45 | + Math.floor(monthly.length * 0.2), |
| 46 | + Math.floor(monthly.length * 0.4), |
| 47 | + Math.floor(monthly.length * 0.6), |
| 48 | + Math.floor(monthly.length * 0.8) |
32 | 49 | ]) |
33 | 50 | ) |
34 | 51 | : []; |
|
65 | 82 | <div class="histogram-wrap" bind:this={histoWrap}> |
66 | 83 | <div class="histogram-meta"> |
67 | 84 | <span class="histogram-title">Tasks merged per month</span> |
68 | | - <span class="histogram-axis">peak: {peakCount}</span> |
69 | 85 | </div> |
70 | | - <div |
71 | | - class="histogram" |
72 | | - role="img" |
73 | | - aria-label="Monthly distribution of tasks" |
74 | | - on:mouseleave={clearHover} |
75 | | - > |
76 | | - {#each monthly as m, i} |
| 86 | + <div class="chart-area"> |
| 87 | + <div class="y-axis" aria-hidden="true"> |
| 88 | + {#each yTicks as t} |
| 89 | + <span class="y-tick" style="bottom: {(t / axisMax) * 100}%">{t}</span> |
| 90 | + {/each} |
| 91 | + </div> |
| 92 | + <div class="plot"> |
| 93 | + <div class="gridlines" aria-hidden="true"> |
| 94 | + {#each yTicks as t} |
| 95 | + <div |
| 96 | + class="gridline" |
| 97 | + style="bottom: {(t / axisMax) * 100}%" |
| 98 | + ></div> |
| 99 | + {/each} |
| 100 | + </div> |
77 | 101 | <div |
78 | | - class="bar" |
79 | | - class:active={hovered === m} |
80 | | - style="height: {(m.count / peakCount) * 100}%; --i: {i}" |
81 | | - aria-label={`${monthLabel(m.month)}: ${m.count} task${m.count === 1 ? "" : "s"}`} |
82 | | - on:mouseenter={(e) => hoverBar(m, e)} |
83 | | - on:mousemove={(e) => hoverBar(m, e)} |
84 | | - ></div> |
85 | | - {/each} |
| 102 | + class="histogram" |
| 103 | + role="img" |
| 104 | + aria-label="Monthly distribution of tasks" |
| 105 | + on:mouseleave={clearHover} |
| 106 | + > |
| 107 | + {#each monthly as m, i} |
| 108 | + <div |
| 109 | + class="bar" |
| 110 | + class:active={hovered === m} |
| 111 | + style="height: {(m.count / axisMax) * 100}%; --i: {i}" |
| 112 | + aria-label={`${monthLabel(m.month)}: ${m.count} task${m.count === 1 ? "" : "s"}`} |
| 113 | + on:mouseenter={(e) => hoverBar(m, e)} |
| 114 | + on:mousemove={(e) => hoverBar(m, e)} |
| 115 | + ></div> |
| 116 | + {/each} |
| 117 | + </div> |
| 118 | + <div class="histogram-axis-row"> |
| 119 | + {#each monthly as m, i} |
| 120 | + <span class="tick" class:show={tickIdxs.includes(i)}> |
| 121 | + {tickIdxs.includes(i) ? monthLabel(m.month) : ""} |
| 122 | + </span> |
| 123 | + {/each} |
| 124 | + </div> |
| 125 | + </div> |
86 | 126 | </div> |
87 | 127 |
|
88 | 128 | {#if hovered} |
|
98 | 138 | </div> |
99 | 139 | </div> |
100 | 140 | {/if} |
101 | | - <div class="histogram-axis-row"> |
102 | | - {#each monthly as m, i} |
103 | | - <span class="tick" class:show={tickIdxs.includes(i)}> |
104 | | - {tickIdxs.includes(i) ? monthLabel(m.month) : ""} |
105 | | - </span> |
106 | | - {/each} |
107 | | - </div> |
108 | 141 | </div> |
109 | 142 | </div> |
110 | 143 |
|
|
183 | 216 | letter-spacing: 0.05em; |
184 | 217 | } |
185 | 218 |
|
| 219 | + .chart-area { |
| 220 | + display: flex; |
| 221 | + gap: 8px; |
| 222 | + align-items: stretch; |
| 223 | + } |
| 224 | +
|
| 225 | + .y-axis { |
| 226 | + position: relative; |
| 227 | + width: 22px; |
| 228 | + flex-shrink: 0; |
| 229 | + height: 160px; |
| 230 | + font-family: var(--mono); |
| 231 | + font-size: 0.65rem; |
| 232 | + color: var(--text-muted); |
| 233 | + } |
| 234 | +
|
| 235 | + .y-tick { |
| 236 | + position: absolute; |
| 237 | + right: 0; |
| 238 | + transform: translateY(50%); |
| 239 | + line-height: 1; |
| 240 | + text-align: right; |
| 241 | + } |
| 242 | +
|
| 243 | + .plot { |
| 244 | + position: relative; |
| 245 | + flex: 1 1 0; |
| 246 | + min-width: 0; |
| 247 | + } |
| 248 | +
|
| 249 | + .gridlines { |
| 250 | + position: absolute; |
| 251 | + inset: 0 0 auto 0; |
| 252 | + height: 160px; |
| 253 | + pointer-events: none; |
| 254 | + } |
| 255 | +
|
| 256 | + .gridline { |
| 257 | + position: absolute; |
| 258 | + left: 0; |
| 259 | + right: 0; |
| 260 | + height: 1px; |
| 261 | + background: var(--border-primary); |
| 262 | + opacity: 0.5; |
| 263 | + } |
| 264 | +
|
186 | 265 | .histogram { |
| 266 | + position: relative; |
187 | 267 | display: flex; |
188 | 268 | align-items: flex-end; |
189 | 269 | gap: 2px; |
|
0 commit comments