|
| 1 | +<script lang="ts"> |
| 2 | + import { format_number } from '$lib/format-number' |
| 3 | +
|
| 4 | + type Props = { |
| 5 | + data: Record<string, number> |
| 6 | + formatter?: (value: number) => string |
| 7 | + title: string |
| 8 | + alt: string |
| 9 | + } |
| 10 | +
|
| 11 | + let { data, formatter = format_number, title, alt }: Props = $props() |
| 12 | +
|
| 13 | + // Chart dimensions |
| 14 | + const width = 500 |
| 15 | + const height = 200 |
| 16 | + const margin = { top: 20, right: 30, bottom: 25, left: 60 } |
| 17 | + const chart_width = width - margin.left - margin.right |
| 18 | + const chart_height = height - margin.top - margin.bottom |
| 19 | + const bar_width = 20 |
| 20 | + const num_y_ticks = 10 |
| 21 | +
|
| 22 | + // Calculate scales - derived from data |
| 23 | + const max_value = $derived(Math.max(...Object.values(data))) |
| 24 | + const y_max = $derived.by(() => { |
| 25 | + // Determine rounding magnitude based on max value |
| 26 | + if (max_value === 0) { |
| 27 | + return 1 |
| 28 | + } else if (max_value < 1) { |
| 29 | + return Math.ceil(max_value * 10) / 10 // Round to nearest 0.1 |
| 30 | + } else if (max_value <= 100) { |
| 31 | + return Math.ceil(max_value / 10) * 10 // Round to nearest 10 |
| 32 | + } |
| 33 | + return Math.ceil(max_value / 100) * 100 // Round to nearest 100 |
| 34 | + }) |
| 35 | + const y_scale = $derived(chart_height / y_max) |
| 36 | + const bar_spacing = $derived(chart_width / Object.values(data).length) |
| 37 | +
|
| 38 | + // Generate y-axis ticks with nice round numbers |
| 39 | + const y_ticks = $derived.by(() => { |
| 40 | + // When all values are 0, only show a 0 tick |
| 41 | + if (max_value === 0) { |
| 42 | + return [{ value: 0, y: chart_height }] |
| 43 | + } |
| 44 | +
|
| 45 | + // Calculate ideal step size |
| 46 | + const ideal_step = y_max / (num_y_ticks - 1) |
| 47 | +
|
| 48 | + // Find magnitude and round to nice increment (1, 2, 5, 10, 20, 50, 100, etc.) |
| 49 | + const magnitude = Math.pow(10, Math.floor(Math.log10(ideal_step))) |
| 50 | + const normalized = ideal_step / magnitude |
| 51 | + const nice_step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10 |
| 52 | + const step = nice_step * magnitude |
| 53 | +
|
| 54 | + // Generate ticks from 0 to yMax using the nice step |
| 55 | + const ticks = [] |
| 56 | + for (let value = 0; value <= y_max; value += step) { |
| 57 | + const y = chart_height - value * y_scale |
| 58 | + ticks.push({ value, y }) |
| 59 | + } |
| 60 | + return ticks |
| 61 | + }) |
| 62 | +
|
| 63 | + // Generate bars |
| 64 | + const bars = $derived( |
| 65 | + Object.entries(data).map(([label, value], i) => { |
| 66 | + const x = i * bar_spacing + bar_spacing / 2 - bar_width / 2 |
| 67 | + const bar_height = value * y_scale |
| 68 | + const y = chart_height - bar_height |
| 69 | + return { |
| 70 | + x, |
| 71 | + y, |
| 72 | + width: bar_width, |
| 73 | + height: bar_height, |
| 74 | + label: label, |
| 75 | + value: value, |
| 76 | + center_x: i * bar_spacing + bar_spacing / 2 |
| 77 | + } |
| 78 | + }) |
| 79 | + ) |
| 80 | +
|
| 81 | + let uid = $props.id() |
| 82 | + let title_id = `${uid}-title` |
| 83 | + let description_id = `${uid}-description` |
| 84 | +</script> |
| 85 | + |
| 86 | +<div class="bar-chart"> |
| 87 | + <!-- Accessible title is in the SVG itself as title --> |
| 88 | + <div id={title_id} class="[ title ] chart-title" aria-hidden="true">{title}</div> |
| 89 | + |
| 90 | + <svg |
| 91 | + xmlns="http://www.w3.org/2000/svg" |
| 92 | + class="chart" |
| 93 | + role="img" |
| 94 | + aria-labelledby={`${title_id} ${description_id}`} |
| 95 | + viewBox="0 0 {width} {height}" |
| 96 | + > |
| 97 | + <title id={title_id}>{title}</title> |
| 98 | + <desc id={description_id}>{alt}</desc> |
| 99 | + <g transform="translate({margin.left},{margin.top})"> |
| 100 | + <!-- Grid lines --> |
| 101 | + <g class="grid" style="stroke: currentcolor; opacity: 0.2;" fill="none" text-anchor="end"> |
| 102 | + <path class="domain" stroke="currentColor" d="M{chart_width},{chart_height}H0V0H{chart_width}" /> |
| 103 | + {#each y_ticks as tick} |
| 104 | + <g class="tick" opacity="1" transform="translate(0,{tick.y})"> |
| 105 | + <line stroke="currentColor" x2={chart_width} /> |
| 106 | + </g> |
| 107 | + {/each} |
| 108 | + </g> |
| 109 | + |
| 110 | + <!-- Y-axis --> |
| 111 | + <g fill="none" text-anchor="end"> |
| 112 | + <path stroke="currentColor" d="M-6,{chart_height}H0V0H-6" /> |
| 113 | + {#each y_ticks as tick} |
| 114 | + <g class="tick" opacity="1" transform="translate(0,{tick.y})"> |
| 115 | + <line stroke="currentColor" x2="-6" /> |
| 116 | + <text fill="currentColor" x="-9" dy="0.32em" class="axis-label"> |
| 117 | + {formatter(tick.value)} |
| 118 | + </text> |
| 119 | + </g> |
| 120 | + {/each} |
| 121 | + </g> |
| 122 | + |
| 123 | + <!-- X-axis --> |
| 124 | + <g transform="translate(0,{chart_height})" fill="none" text-anchor="middle"> |
| 125 | + <path stroke="currentColor" d="M0,6V0H{chart_width}V6" /> |
| 126 | + {#each bars as bar} |
| 127 | + <g class="tick" opacity="1" transform="translate({bar.center_x},0)"> |
| 128 | + <line stroke="currentColor" y2="6" /> |
| 129 | + <text fill="currentColor" y="9" dy="0.7em" text-anchor="middle" class="axis-label"> |
| 130 | + {bar.label} |
| 131 | + </text> |
| 132 | + </g> |
| 133 | + {/each} |
| 134 | + </g> |
| 135 | + |
| 136 | + <!-- Bars --> |
| 137 | + {#each bars as bar} |
| 138 | + <rect x={bar.x} y={bar.y} width={bar.width} height={bar.height} fill="currentColor" class="bar" /> |
| 139 | + <text x={bar.center_x} y={bar.y - 10} text-anchor="middle" fill="currentColor" class="bar-label"> |
| 140 | + {formatter(bar.value)} |
| 141 | + </text> |
| 142 | + {/each} |
| 143 | + </g> |
| 144 | + </svg> |
| 145 | + |
| 146 | + <details> |
| 147 | + <summary>View chart as table</summary> |
| 148 | + <table> |
| 149 | + <caption>{title}</caption> |
| 150 | + <thead> |
| 151 | + <tr> |
| 152 | + <th scope="col">Percentile</th> |
| 153 | + <th scope="col">Value</th> |
| 154 | + </tr> |
| 155 | + </thead> |
| 156 | + <tbody> |
| 157 | + {#each bars as bar} |
| 158 | + <tr> |
| 159 | + <th scope="row">{bar.label}</th> |
| 160 | + <td>{formatter(bar.value)}</td> |
| 161 | + </tr> |
| 162 | + {/each} |
| 163 | + </tbody> |
| 164 | + </table> |
| 165 | + </details> |
| 166 | +</div> |
| 167 | + |
| 168 | +<style> |
| 169 | + .chart-title { |
| 170 | + font-size: var(--size-sm); |
| 171 | + text-align: center; |
| 172 | + } |
| 173 | +
|
| 174 | + .bar-label, |
| 175 | + .axis-label { |
| 176 | + font-size: 0.7rem; |
| 177 | +
|
| 178 | + @media (min-width: 60rem) { |
| 179 | + font-size: 0.55rem; |
| 180 | + } |
| 181 | + } |
| 182 | +
|
| 183 | + .bar { |
| 184 | + fill: var(--accent-400); |
| 185 | + } |
| 186 | +
|
| 187 | + .bar-label { |
| 188 | + fill: var(--fg-200); |
| 189 | + } |
| 190 | +
|
| 191 | + svg { |
| 192 | + color: var(--fg-300); |
| 193 | + } |
| 194 | +
|
| 195 | + text { |
| 196 | + fill: currentColor; |
| 197 | + color: var(--fg-200); |
| 198 | + } |
| 199 | +
|
| 200 | + summary { |
| 201 | + text-align: center; |
| 202 | + font-size: var(--size-base); |
| 203 | + } |
| 204 | +</style> |
0 commit comments