|
1 | 1 | <script lang="ts"> |
2 | | - import { AXIS_BOX, mapX, mapY, buildPath, type Sample } from './curves'; |
| 2 | + import { AXIS_BOX, PLOT_BOX, mapX, mapY, buildPath, type Sample } from './curves'; |
3 | 3 |
|
4 | | - type AxesMode = 'none' | 'baseline' | 'cross'; |
| 4 | + /** 'baseline' = x only, 'yaxis' = y only, 'cross' = both. */ |
| 5 | + type AxesMode = 'none' | 'baseline' | 'yaxis' | 'cross'; |
5 | 6 | type Decoration = 'arrow-up' | 'arrow-down'; |
6 | 7 |
|
7 | 8 | interface Props { |
|
12 | 13 | axes?: AxesMode; |
13 | 14 | markers?: boolean; |
14 | 15 | decoration?: Decoration; |
| 16 | + /** Vertical asymptotes in sample-x coordinates, drawn dashed. */ |
| 17 | + asymptotes?: number[]; |
| 18 | + /** Small superscript label in the top-right corner (e.g. base of a log). */ |
| 19 | + badge?: string; |
| 20 | + /** Draw samples as stems from the zero line instead of a connected line. */ |
| 21 | + stems?: boolean; |
15 | 22 | } |
16 | 23 |
|
17 | 24 | let { |
|
21 | 28 | yRange = [0, 1], |
22 | 29 | axes = 'cross', |
23 | 30 | markers = false, |
24 | | - decoration |
| 31 | + decoration, |
| 32 | + asymptotes, |
| 33 | + badge, |
| 34 | + stems = false |
25 | 35 | }: Props = $props(); |
26 | 36 |
|
27 | 37 | const path = $derived(buildPath(samples, xRange[0], xRange[1], yRange[0], yRange[1])); |
|
31 | 41 | : '' |
32 | 42 | ); |
33 | 43 |
|
| 44 | + // The zero line only sits inside the plot when 0 is actually part of the |
| 45 | + // value range; otherwise it falls back to the box edge. For x this uses a |
| 46 | + // strict test, so a time signal (x starting at 0) keeps its y-axis just |
| 47 | + // left of the trace instead of drawing it straight through the first edge. |
34 | 48 | const xAxisY = $derived( |
35 | 49 | yRange[0] <= 0 && yRange[1] >= 0 ? mapY(0, yRange[0], yRange[1]) : AXIS_BOX.y1 |
36 | 50 | ); |
37 | 51 | const yAxisX = $derived( |
38 | | - xRange[0] <= 0 && xRange[1] >= 0 ? mapX(0, xRange[0], xRange[1]) : AXIS_BOX.x0 |
| 52 | + xRange[0] < 0 && xRange[1] > 0 ? mapX(0, xRange[0], xRange[1]) : AXIS_BOX.x0 |
39 | 53 | ); |
40 | 54 |
|
41 | 55 | const finiteSamples = $derived(samples.filter(([, v]) => Number.isFinite(v))); |
| 56 | + const asymptoteX = $derived( |
| 57 | + (asymptotes ?? []).map((x) => mapX(x, xRange[0], xRange[1])) |
| 58 | + ); |
42 | 59 | </script> |
43 | 60 |
|
44 | 61 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 64" fill="none" stroke="currentColor" |
45 | | - stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> |
46 | | - {#if axes === 'baseline' || axes === 'cross'} |
47 | | - <line x1={AXIS_BOX.x0} y1={xAxisY} x2={AXIS_BOX.x1} y2={xAxisY} /> |
48 | | - {/if} |
49 | | - {#if axes === 'cross'} |
50 | | - <line x1={yAxisX} y1={AXIS_BOX.y0} x2={yAxisX} y2={AXIS_BOX.y1} /> |
| 62 | + stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"> |
| 63 | + <!-- Axes are drawn first so the trace stays on top where they cross. --> |
| 64 | + {#if axes !== 'none'} |
| 65 | + <g class="axis"> |
| 66 | + {#if axes === 'baseline' || axes === 'cross'} |
| 67 | + <line x1={AXIS_BOX.x0} y1={xAxisY} x2={AXIS_BOX.x1} y2={xAxisY} /> |
| 68 | + {/if} |
| 69 | + {#if axes === 'yaxis' || axes === 'cross'} |
| 70 | + <line x1={yAxisX} y1={AXIS_BOX.y0} x2={yAxisX} y2={AXIS_BOX.y1} /> |
| 71 | + {/if} |
| 72 | + </g> |
51 | 73 | {/if} |
| 74 | + {#each asymptoteX as ax} |
| 75 | + <line class="asymptote" x1={ax} y1={PLOT_BOX.y0} x2={ax} y2={PLOT_BOX.y1} /> |
| 76 | + {/each} |
52 | 77 | {#if pathDashed} |
53 | | - <path d={pathDashed} stroke-dasharray="3 2.5" /> |
| 78 | + <path d={pathDashed} class="ghost" stroke-dasharray="3.5 3" /> |
| 79 | + {/if} |
| 80 | + {#if stems} |
| 81 | + {#each finiteSamples as [x, v]} |
| 82 | + {@const sx = mapX(x, xRange[0], xRange[1])} |
| 83 | + <line x1={sx} y1={xAxisY} x2={sx} y2={mapY(v, yRange[0], yRange[1])} /> |
| 84 | + <circle cx={sx} cy={mapY(v, yRange[0], yRange[1])} r="2.4" fill="currentColor" stroke="none" /> |
| 85 | + {/each} |
| 86 | + {:else} |
| 87 | + <path d={path} /> |
54 | 88 | {/if} |
55 | | - <path d={path} /> |
56 | 89 | {#if markers} |
57 | 90 | {#each finiteSamples as [x, v]} |
58 | 91 | <circle |
59 | 92 | cx={mapX(x, xRange[0], xRange[1])} |
60 | 93 | cy={mapY(v, yRange[0], yRange[1])} |
61 | | - r="3" |
| 94 | + r="2.8" |
62 | 95 | fill="currentColor" |
63 | 96 | stroke="none" |
64 | 97 | /> |
65 | 98 | {/each} |
66 | 99 | {/if} |
67 | 100 | {#if decoration === 'arrow-up'} |
68 | | - <path d="M 88 40 L 88 24 M 84 28 L 88 24 L 92 28" /> |
| 101 | + <path d="M 86 44 L 86 22 M 82 26 L 86 22 L 90 26" /> |
69 | 102 | {:else if decoration === 'arrow-down'} |
70 | | - <path d="M 88 24 L 88 40 M 84 36 L 88 40 L 92 36" /> |
| 103 | + <path d="M 86 22 L 86 44 M 82 40 L 86 44 L 90 40" /> |
| 104 | + {/if} |
| 105 | + {#if badge} |
| 106 | + <!-- Top-left: the corner a rising characteristic leaves free. --> |
| 107 | + <text |
| 108 | + x={AXIS_BOX.x0 + 4} |
| 109 | + y={AXIS_BOX.y0} |
| 110 | + text-anchor="start" |
| 111 | + dominant-baseline="hanging" |
| 112 | + fill="currentColor" |
| 113 | + stroke="none" |
| 114 | + font-family="ui-monospace, 'JetBrains Mono', 'SF Mono', Menlo, monospace" |
| 115 | + font-size="11" |
| 116 | + font-weight="600">{badge}</text |
| 117 | + > |
71 | 118 | {/if} |
72 | 119 | </svg> |
73 | 120 |
|
|
77 | 124 | height: 100%; |
78 | 125 | display: block; |
79 | 126 | } |
| 127 | +
|
| 128 | + /* Axes carry the same weight as the trace — at canvas scale anything |
| 129 | + * lighter disappears. */ |
| 130 | + .axis { |
| 131 | + stroke-width: 1.6; |
| 132 | + } |
| 133 | +
|
| 134 | + .asymptote { |
| 135 | + stroke-width: 1.2; |
| 136 | + opacity: 0.55; |
| 137 | + stroke-dasharray: 3 3; |
| 138 | + } |
| 139 | +
|
| 140 | + .ghost { |
| 141 | + opacity: 0.5; |
| 142 | + } |
80 | 143 | </style> |
0 commit comments