@@ -32,13 +32,13 @@ interface Chart {
3232 innerHeight : number ;
3333 scalePropsX : ScaleProps ;
3434 scalePropsY : ScaleProps ;
35+ originalDomainX : [ number , number ] ;
36+ originalDomainY : [ number , number ] ;
3537 scaleX : SupportedScaleTypes ;
3638 scaleY : SupportedScaleTypes ;
3739 formatX : ( value : number ) => string ;
3840 formatY : ( value : number ) => string ;
3941 transformX ?: ( x : number , y : number , scaleY : SupportedScaleTypes ) => number ;
40- zoom : number ;
41- pan : [ number , number ] ;
4242}
4343type SetChart = SetStoreFunction < Chart > ;
4444const ChartContext = createContext < [ Chart , SetChart ] > ( ) ;
@@ -65,39 +65,26 @@ export function ChartContainer(props: {
6565 innerWidth,
6666 scalePropsX : { type : "linear" , domain : [ 0 , 1 ] , range : [ 0 , innerWidth ] } ,
6767 scalePropsY : { type : "linear" , domain : [ 0 , 1 ] , range : [ innerHeight , 0 ] } ,
68+ originalDomainX : [ 0 , 1 ] ,
69+ originalDomainY : [ 0 , 1 ] ,
6870 scaleX : initialScale ,
6971 scaleY : initialScale ,
7072 formatX : d3 . format ( ".4" ) ,
7173 formatY : d3 . format ( ".4" ) ,
72- zoom : 1 ,
73- pan : [ 0 , 0 ] ,
7474 } ) ;
75+ // Set original domains based on initial scale props
76+ updateChart ( "originalDomainX" , ( ) => chart . scalePropsX . domain ) ;
77+ updateChart ( "originalDomainY" , ( ) => chart . scalePropsY . domain ) ;
7578
7679 // Update scales when props change
7780 createEffect ( ( ) => {
78- const [ minX , maxX ] = chart . scalePropsX . domain ;
79- const [ minY , maxY ] = chart . scalePropsY . domain ;
80- const [ panX , panY ] = chart . pan ;
81- const zoom = chart . zoom ;
82-
83- const zoomedXDomain = getZoomedAndPannedDomainLinear (
84- minX ,
85- maxX ,
86- panX ,
87- zoom ,
88- ) ;
8981 const scaleX = supportedScales [ chart . scalePropsX . type ] ( )
9082 . range ( chart . scalePropsX . range )
91- . domain ( zoomedXDomain ) ;
92-
93- const zoomedYDomain =
94- chart . scalePropsY . type === "log"
95- ? getZoomedAndPannedDomainLog ( minY , maxY , panY , zoom )
96- : getZoomedAndPannedDomainLinear ( minY , maxY , panY , zoom ) ;
83+ . domain ( chart . scalePropsX . domain ) ;
9784
9885 const scaleY = supportedScales [ chart . scalePropsY . type ] ( )
9986 . range ( chart . scalePropsY . range )
100- . domain ( zoomedYDomain ) ;
87+ . domain ( chart . scalePropsY . domain ) ;
10188
10289 updateChart (
10390 produce ( ( prev ) => {
@@ -123,140 +110,171 @@ export function Chart(props: {
123110 formatY ?: ( ) => ( value : number ) => string ;
124111 transformX ?: ( x : number , y : number , scaleY : SupportedScaleTypes ) => number ;
125112} ) {
113+ const [ zoomRectData , setZoomRectData ] = createSignal < {
114+ x0 : number ;
115+ y0 : number ;
116+ x1 : number ;
117+ y1 : number ;
118+ } | null > ( null ) ;
119+ const [ zoomRectPixel , setZoomRectPixel ] = createSignal < {
120+ x0 : number ;
121+ y0 : number ;
122+ x1 : number ;
123+ y1 : number ;
124+ } | null > ( null ) ;
126125 const [ hovering , setHovering ] = createSignal ( false ) ;
127- const [ panning , setPanning ] = createSignal ( false ) ;
128126 const [ dataCoords , setDataCoords ] = createSignal < [ number , number ] > ( [ 0 , 0 ] ) ;
129127 const [ chart , updateChart ] = useChartContext ( ) ;
130128 const title = props . title || "Default chart" ;
131129 const [ marginTop , _ , __ , marginLeft ] = chart . margin ;
132- let panstart = [ 0 , 0 ] ;
133130
131+ function resetZoom ( ) {
132+ updateChart (
133+ produce ( ( draft ) => {
134+ draft . scalePropsX . domain = draft . originalDomainX ;
135+ draft . scalePropsY . domain = draft . originalDomainY ;
136+ } ) ,
137+ ) ;
138+ }
139+
140+ // Reset zoom/pan when requested from outside (button outside chart area)
134141 createEffect ( ( ) => {
135142 if ( resetPlot ( ) === props . id ) {
136- updateChart (
137- produce ( ( prev ) => {
138- prev . zoom = 1 ;
139- prev . pan = [ 0 , 0 ] ;
140- } ) ,
141- ) ;
143+ resetZoom ( ) ;
142144 }
143145 } ) ;
144146
147+ // Update formatters and transform function when props change
145148 createEffect ( ( ) => {
146- if ( props . formatX ) {
147- updateChart ( "formatX " , ( ) => props . formatX ?.( ) ) ;
148- }
149+ if ( props . formatX ) updateChart ( "formatX" , ( ) => props . formatX ?. ( ) ) ;
150+ if ( props . formatY ) updateChart ( "formatY " , ( ) => props . formatY ?.( ) ) ;
151+ if ( props . transformX ) updateChart ( "transformX" , ( ) => props . transformX ) ;
149152 } ) ;
150- createEffect ( ( ) => {
151- if ( props . formatY ) {
152- updateChart ( "formatY" , ( ) => props . formatY ?.( ) ) ;
153- }
154- } ) ;
155-
156- if ( props . transformX ) {
157- updateChart ( "transformX" , ( ) => props . transformX ) ;
158- }
159153
160154 // Utility function to calculate coordinates from mouse event
161- const getDataCoordsFromEvent = ( e : MouseEvent ) => {
155+ const getDataCoordsFromEvent = ( e : MouseEvent , applyTransform = true ) => {
162156 let x = e . offsetX - marginLeft ;
163157 const y = e . offsetY - marginTop ;
164158
165- if ( chart . transformX ) {
159+ if ( applyTransform && chart . transformX ) {
166160 // Correct for skewed lines in thermodynamic diagram
167161 x = chart . transformX ( x , y , chart . scaleY ) ;
168162 }
169163
170164 return [ chart . scaleX . invert ( x ) , chart . scaleY . invert ( y ) ] ;
171165 } ;
172166
167+ function getPixelCoordsFromEvent ( e : MouseEvent ) {
168+ const x = e . offsetX - marginLeft ; // x relative to chart area
169+ const y = e . offsetY - marginTop ; // y relative to chart area
170+ return [ x , y ] as [ number , number ] ;
171+ }
172+
173173 const onMouseDown = ( e : MouseEvent ) => {
174- setPanning ( true ) ;
175- panstart = getDataCoordsFromEvent ( e ) ;
174+ const [ xd , yd ] = getDataCoordsFromEvent ( e , false ) ;
175+ const [ xp , yp ] = getPixelCoordsFromEvent ( e ) ;
176+
177+ setZoomRectPixel ( { x0 : xp , y0 : yp , x1 : xp , y1 : yp } ) ;
178+ setZoomRectData ( { x0 : xd , y0 : yd , x1 : xd , y1 : yd } ) ;
176179 } ;
177180
178181 const onMouseMove = ( e : MouseEvent ) => {
179- const [ x , y ] = getDataCoordsFromEvent ( e ) ;
182+ // Update the coordinate tracker in the plot
183+ const [ xdSkew , ydSkew ] = getDataCoordsFromEvent ( e , true ) ;
184+ setDataCoords ( [ xdSkew , ydSkew ] ) ;
180185
181- if ( panning ( ) ) {
182- const [ startX , startY ] = panstart ;
186+ // Update zoom rectangle if drawing
187+ const [ xd , yd ] = getDataCoordsFromEvent ( e , false ) ;
188+ const [ xp , yp ] = getPixelCoordsFromEvent ( e ) ;
183189
184- const dx =
185- chart . scalePropsX . type === "log"
186- ? Math . log10 ( x ) - Math . log10 ( startX )
187- : x - startX ;
190+ setZoomRectPixel ( ( zr ) => ( zr ? { ...zr , x1 : xp , y1 : yp } : null ) ) ;
191+ setZoomRectData ( ( zr ) => ( zr ? { ...zr , x1 : xd , y1 : yd } : null ) ) ;
192+ } ;
188193
189- const dy =
190- chart . scalePropsY . type === "log"
191- ? Math . log10 ( y ) - Math . log10 ( startY )
192- : y - startY ;
194+ const onMouseUp = ( ) => {
195+ // Apply zoom if a rectangle was drawn
196+ const newZoomData = zoomRectData ( ) ; // enable type narrowing for null check
197+ const newZoomPixels = zoomRectData ( ) ;
193198
194- updateChart ( "pan" , ( prev ) => [ prev [ 0 ] - dx , prev [ 1 ] - dy ] ) ;
195- } else {
196- // Update the coordinate tracker in the plot
197- setDataCoords ( [ x , y ] ) ;
198- }
199- } ;
199+ if ( ! newZoomData || ! newZoomPixels ) return ;
200200
201- const onWheel = ( e : WheelEvent ) => {
202- // Zoom towards cursor
203- e . preventDefault ( ) ;
204- const zoomFactor = 1.1 ;
205- const zoomDirection = e . deltaY < 0 ? 1 : - 1 ;
206- const zoomChange = zoomFactor ** zoomDirection ;
201+ // Don't zoom if the rectangle is too small (ie just a click)
202+ const { x0 : x0p , x1 : x1p , y0 : y0p , y1 : y1p } = newZoomPixels ;
203+ if ( Math . abs ( x1p - x0p ) < 5 || Math . abs ( y1p - y0p ) < 5 ) {
204+ setZoomRectData ( null ) ;
205+ setZoomRectPixel ( null ) ;
206+ return ;
207+ }
207208
208- const [ cursorX , cursorY ] = getDataCoordsFromEvent ( e ) ;
209+ const { x0 , x1 , y0 , y1 } = newZoomData ;
209210
210211 updateChart (
211212 produce ( ( draft ) => {
212- const { scalePropsX, scalePropsY, pan } = draft ;
213- const [ panX , panY ] = pan ;
214-
215- // Calculate x-pan (linear only for now)
216- const [ xmin , xmax ] = scalePropsX . domain ;
217- const centerX = ( xmin + xmax ) / 2 + panX ;
218- const dx = cursorX - centerX ;
219-
220- // Calculate y-pan
221- const [ ymin , ymax ] = scalePropsY . domain ;
222- let dy : number ;
223- if ( scalePropsY . type === "log" ) {
224- const logCursor = Math . log10 ( Math . max ( cursorY , 1e-10 ) ) ;
225- const logCenter = ( Math . log10 ( ymin ) + Math . log10 ( ymax ) ) / 2 + panY ;
226- dy = logCursor - logCenter ;
227- } else {
228- const centerY = ( ymin + ymax ) / 2 + panY ;
229- dy = cursorY - centerY ;
230- }
231-
232- // Update the chart (mutating plays nicely with produce)
233- draft . zoom *= zoomChange ;
234- draft . pan [ 0 ] += dx * ( 1 - 1 / zoomChange ) ;
235- draft . pan [ 1 ] += dy * ( 1 - 1 / zoomChange ) ;
213+ // Handle log scales
214+ const scaleX = draft . scalePropsX ;
215+ const scaleY = draft . scalePropsY ;
216+
217+ draft . scalePropsX . domain =
218+ scaleX . type === "log"
219+ ? [ Math . max ( Math . min ( x0 , x1 ) , 1e-10 ) , Math . max ( x0 , x1 ) ]
220+ : [ Math . min ( x0 , x1 ) , Math . max ( x0 , x1 ) ] ;
221+
222+ draft . scalePropsY . domain =
223+ // logY is used for skew-T, use inverse Y-axis and prevent zero/negative
224+ scaleY . type === "log"
225+ ? [ Math . max ( y0 , y1 ) , Math . max ( Math . min ( y0 , y1 ) , 1e-10 ) ]
226+ : [ Math . min ( y0 , y1 ) , Math . max ( y0 , y1 ) ] ;
236227 } ) ,
237228 ) ;
229+
230+ setZoomRectData ( null ) ;
231+ setZoomRectPixel ( null ) ;
232+ } ;
233+
234+ const cancelZoomRect = ( ) => {
235+ setZoomRectData ( null ) ;
236+ setZoomRectPixel ( null ) ;
238237 } ;
239238
240239 const renderXCoord = ( ) =>
241240 hovering ( ) ? `x: ${ chart . formatX ( dataCoords ( ) [ 0 ] ) } ` : "" ;
242241 const renderYCoord = ( ) =>
243242 hovering ( ) ? `y: ${ chart . formatY ( dataCoords ( ) [ 1 ] ) } ` : "" ;
244243
244+ const drawZoomRect = ( ) => {
245+ const newBounds = zoomRectPixel ( ) ;
246+ if ( ! newBounds ) return ;
247+
248+ const { x0, y0, x1, y1 } = newBounds ;
249+
250+ return (
251+ < rect
252+ x = { Math . min ( x0 , x1 ) }
253+ y = { Math . min ( y0 , y1 ) }
254+ width = { Math . abs ( x1 - x0 ) }
255+ height = { Math . abs ( y1 - y0 ) }
256+ fill = "rgba(0,0,255,0.2)"
257+ stroke = "blue"
258+ stroke-width = { 1 }
259+ />
260+ ) ;
261+ } ;
262+
245263 return (
246264 < svg
247265 width = { chart . width }
248266 height = { chart . height }
249267 class = { cn (
250268 "text-slate-500 text-xs tracking-wide" ,
251- panning ( ) ? "cursor-grabbing select-none" : "cursor-grab " ,
269+ zoomRectData ( ) ? "cursor-crosshair select-none" : "cursor-crosshair " ,
252270 ) }
253271 onmouseover = { ( ) => setHovering ( true ) }
254272 onmouseout = { ( ) => setHovering ( false ) }
255273 onmousedown = { onMouseDown }
256- onmouseup = { ( ) => setPanning ( false ) }
257274 onmousemove = { onMouseMove }
258- onmouseleave = { ( ) => setPanning ( false ) }
259- onwheel = { onWheel }
275+ onmouseup = { onMouseUp }
276+ ondblclick = { resetZoom }
277+ onmouseleave = { cancelZoomRect }
260278 >
261279 < title > { title } </ title >
262280 < g transform = { `translate(${ marginLeft } ,${ marginTop } )` } >
@@ -267,6 +285,7 @@ export function Chart(props: {
267285 < text x = "5" y = "20" >
268286 { renderYCoord ( ) }
269287 </ text >
288+ { zoomRectData ( ) && drawZoomRect ( ) }
270289 </ g >
271290 < ClipPath />
272291 </ svg >
@@ -309,32 +328,3 @@ export function highlight(hex: string) {
309328 . padStart ( 2 , "0" ) ;
310329 return `#${ b ( hex , 1 ) } ${ b ( hex , 3 ) } ${ b ( hex , 5 ) } ` ;
311330}
312-
313- function getZoomedAndPannedDomainLinear (
314- min : number ,
315- max : number ,
316- pan : number ,
317- zoom : number ,
318- ) : [ number , number ] {
319- const center = ( min + max ) / 2 + pan ;
320- const halfExtent = ( max - min ) / ( 2 * zoom ) ;
321- return [ center - halfExtent , center + halfExtent ] ;
322- }
323-
324- function getZoomedAndPannedDomainLog (
325- min : number ,
326- max : number ,
327- pan : number ,
328- zoom : number ,
329- ) : [ number , number ] {
330- const logMin = Math . log10 ( min ) ;
331- const logMax = Math . log10 ( max ) ;
332-
333- const logCenter = ( logMin + logMax ) / 2 + pan ;
334- const halfExtent = ( logMax - logMin ) / ( 2 * zoom ) ;
335-
336- const newLogMin = logCenter - halfExtent ;
337- const newLogMax = logCenter + halfExtent ;
338-
339- return [ 10 ** newLogMin , 10 ** newLogMax ] ;
340- }
0 commit comments