66 createSignal ,
77 useContext ,
88} from "solid-js" ;
9- import { type SetStoreFunction , createStore } from "solid-js/store" ;
9+ import { type SetStoreFunction , createStore , produce } from "solid-js/store" ;
10+ import { cn } from "~/lib/utils" ;
11+ import { resetPlot } from "../Analysis" ;
1012
1113export type SupportedScaleTypes =
1214 | d3 . ScaleLinear < number , number , never >
@@ -35,6 +37,8 @@ interface Chart {
3537 formatX : ( value : number ) => string ;
3638 formatY : ( value : number ) => string ;
3739 transformX ?: ( x : number , y : number , scaleY : SupportedScaleTypes ) => number ;
40+ zoom : number ;
41+ pan : [ number , number ] ;
3842}
3943type SetChart = SetStoreFunction < Chart > ;
4044const ChartContext = createContext < [ Chart , SetChart ] > ( ) ;
@@ -65,24 +69,44 @@ export function ChartContainer(props: {
6569 scaleY : initialScale ,
6670 formatX : d3 . format ( ".4" ) ,
6771 formatY : d3 . format ( ".4" ) ,
72+ zoom : 1 ,
73+ pan : [ 0 , 0 ] ,
6874 } ) ;
75+
76+ // Update scales when props change
6977 createEffect ( ( ) => {
70- // Update scaleXInstance when scaleX props change
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+ ) ;
7189 const scaleX = supportedScales [ chart . scalePropsX . type ] ( )
7290 . range ( chart . scalePropsX . range )
73- . domain ( chart . scalePropsX . domain ) ;
74- // .nice(); // TODO: could use this instead of getNiceAxisLimits but messes up skewT
75- updateChart ( "scaleX" , ( ) => scaleX ) ;
76- } ) ;
91+ . domain ( zoomedXDomain ) ;
92+
93+ const zoomedYDomain =
94+ chart . scalePropsY . type === "log"
95+ ? getZoomedAndPannedDomainLog ( minY , maxY , panY , zoom )
96+ : getZoomedAndPannedDomainLinear ( minY , maxY , panY , zoom ) ;
7797
78- createEffect ( ( ) => {
79- // Update scaleYInstance when scaleY props change
8098 const scaleY = supportedScales [ chart . scalePropsY . type ] ( )
8199 . range ( chart . scalePropsY . range )
82- . domain ( chart . scalePropsY . domain ) ;
83- // .nice();
84- updateChart ( "scaleY" , ( ) => scaleY ) ;
100+ . domain ( zoomedYDomain ) ;
101+
102+ updateChart (
103+ produce ( ( prev ) => {
104+ prev . scaleX = scaleX ;
105+ prev . scaleY = scaleY ;
106+ } ) ,
107+ ) ;
85108 } ) ;
109+
86110 return (
87111 < ChartContext . Provider value = { [ chart , updateChart ] } >
88112 < figure > { props . children } </ figure >
@@ -93,16 +117,30 @@ export function ChartContainer(props: {
93117/** Container for chart elements such as axes and lines */
94118export function Chart ( props : {
95119 children : JSX . Element ;
120+ id : string ;
96121 title ?: string ;
97122 formatX ?: ( value : number ) => string ;
98123 formatY ?: ( value : number ) => string ;
99124 transformX ?: ( x : number , y : number , scaleY : SupportedScaleTypes ) => number ;
100125} ) {
101126 const [ hovering , setHovering ] = createSignal ( false ) ;
102- const [ coords , setCoords ] = createSignal < [ number , number ] > ( [ 0 , 0 ] ) ;
127+ const [ panning , setPanning ] = createSignal ( false ) ;
128+ const [ dataCoords , setDataCoords ] = createSignal < [ number , number ] > ( [ 0 , 0 ] ) ;
103129 const [ chart , updateChart ] = useChartContext ( ) ;
104130 const title = props . title || "Default chart" ;
105131 const [ marginTop , _ , __ , marginLeft ] = chart . margin ;
132+ let panstart = [ 0 , 0 ] ;
133+
134+ createEffect ( ( ) => {
135+ if ( resetPlot ( ) === props . id ) {
136+ updateChart (
137+ produce ( ( prev ) => {
138+ prev . zoom = 1 ;
139+ prev . pan = [ 0 , 0 ] ;
140+ } ) ,
141+ ) ;
142+ }
143+ } ) ;
106144
107145 if ( props . formatX ) {
108146 updateChart ( "formatX" , ( ) => props . formatX ) ;
@@ -114,30 +152,106 @@ export function Chart(props: {
114152 updateChart ( "transformX" , ( ) => props . transformX ) ;
115153 }
116154
117- const onMouseMove = ( e : MouseEvent ) => {
155+ // Utility function to calculate coordinates from mouse event
156+ const getDataCoordsFromEvent = ( e : MouseEvent ) => {
118157 let x = e . offsetX - marginLeft ;
119158 const y = e . offsetY - marginTop ;
120159
121160 if ( chart . transformX ) {
161+ // Correct for skewed lines in thermodynamic diagram
122162 x = chart . transformX ( x , y , chart . scaleY ) ;
123163 }
124164
125- setCoords ( [ chart . scaleX . invert ( x ) , chart . scaleY . invert ( y ) ] ) ;
165+ return [ chart . scaleX . invert ( x ) , chart . scaleY . invert ( y ) ] ;
166+ } ;
167+
168+ const onMouseDown = ( e : MouseEvent ) => {
169+ setPanning ( true ) ;
170+ panstart = getDataCoordsFromEvent ( e ) ;
171+ } ;
172+
173+ const onMouseMove = ( e : MouseEvent ) => {
174+ const [ x , y ] = getDataCoordsFromEvent ( e ) ;
175+
176+ if ( panning ( ) ) {
177+ const [ startX , startY ] = panstart ;
178+
179+ const dx =
180+ chart . scalePropsX . type === "log"
181+ ? Math . log10 ( x ) - Math . log10 ( startX )
182+ : x - startX ;
183+
184+ const dy =
185+ chart . scalePropsY . type === "log"
186+ ? Math . log10 ( y ) - Math . log10 ( startY )
187+ : y - startY ;
188+
189+ updateChart ( "pan" , ( prev ) => [ prev [ 0 ] - dx , prev [ 1 ] - dy ] ) ;
190+ } else {
191+ // Update the coordinate tracker in the plot
192+ setDataCoords ( [ x , y ] ) ;
193+ }
194+ } ;
195+
196+ const onWheel = ( e : WheelEvent ) => {
197+ // Zoom towards cursor
198+ e . preventDefault ( ) ;
199+ const zoomFactor = 1.1 ;
200+ const zoomDirection = e . deltaY < 0 ? 1 : - 1 ;
201+ const zoomChange = zoomFactor ** zoomDirection ;
202+
203+ const [ cursorX , cursorY ] = getDataCoordsFromEvent ( e ) ;
204+
205+ updateChart (
206+ produce ( ( draft ) => {
207+ const { scalePropsX, scalePropsY, pan } = draft ;
208+ const [ panX , panY ] = pan ;
209+
210+ // Calculate x-pan (linear only for now)
211+ const [ xmin , xmax ] = scalePropsX . domain ;
212+ const centerX = ( xmin + xmax ) / 2 + panX ;
213+ const dx = cursorX - centerX ;
214+
215+ // Calculate y-pan
216+ const [ ymin , ymax ] = scalePropsY . domain ;
217+ let dy : number ;
218+ if ( scalePropsY . type === "log" ) {
219+ const logCursor = Math . log10 ( Math . max ( cursorY , 1e-10 ) ) ;
220+ const logCenter = ( Math . log10 ( ymin ) + Math . log10 ( ymax ) ) / 2 + panY ;
221+ dy = logCursor - logCenter ;
222+ } else {
223+ const centerY = ( ymin + ymax ) / 2 + panY ;
224+ dy = cursorY - centerY ;
225+ }
226+
227+ // Update the chart (mutating plays nicely with produce)
228+ draft . zoom *= zoomChange ;
229+ draft . pan [ 0 ] += dx * ( 1 - 1 / zoomChange ) ;
230+ draft . pan [ 1 ] += dy * ( 1 - 1 / zoomChange ) ;
231+ } ) ,
232+ ) ;
126233 } ;
127234
128235 const renderXCoord = ( ) =>
129- hovering ( ) ? `x: ${ chart . formatX ( coords ( ) [ 0 ] ) } ` : "" ;
236+ hovering ( ) ? `x: ${ chart . formatX ( dataCoords ( ) [ 0 ] ) } ` : "" ;
130237 const renderYCoord = ( ) =>
131- hovering ( ) ? `y: ${ chart . formatY ( coords ( ) [ 1 ] ) } ` : "" ;
238+ hovering ( ) ? `y: ${ chart . formatY ( dataCoords ( ) [ 1 ] ) } ` : "" ;
132239
133240 return (
134241 < svg
135242 width = { chart . width }
136243 height = { chart . height }
137- class = "text-slate-500 text-xs tracking-wide"
244+ class = { cn (
245+ "text-slate-500 text-xs tracking-wide" ,
246+ panning ( ) ? "cursor-grabbing select-none" : "cursor-grab" ,
247+ ) }
138248 onmouseover = { ( ) => setHovering ( true ) }
139- onmousemove = { onMouseMove }
140249 onmouseout = { ( ) => setHovering ( false ) }
250+ onmousedown = { onMouseDown }
251+ onmouseup = { ( ) => setPanning ( false ) }
252+ onmousemove = { onMouseMove }
253+ onmouseleave = { ( ) => setPanning ( false ) }
254+ onwheel = { onWheel }
141255 >
142256 < title > { title } </ title >
143257 < g transform = { `translate(${ marginLeft } ,${ marginTop } )` } >
@@ -149,6 +263,7 @@ export function Chart(props: {
149263 { renderYCoord ( ) }
150264 </ text >
151265 </ g >
266+ < ClipPath />
152267 </ svg >
153268 ) ;
154269}
@@ -163,6 +278,17 @@ export function useChartContext() {
163278 return context ;
164279}
165280
281+ // To constrain lines and other elements to the axes' extent
282+ function ClipPath ( ) {
283+ const [ chart , _updateChart ] = useChartContext ( ) ;
284+
285+ return (
286+ < clipPath id = "clipper" >
287+ < rect x = "0" y = "0" width = { chart . innerWidth } height = { chart . innerHeight } />
288+ </ clipPath >
289+ ) ;
290+ }
291+
166292export interface ChartData < T > {
167293 label : string ;
168294 color : string ;
@@ -178,3 +304,32 @@ export function highlight(hex: string) {
178304 . padStart ( 2 , "0" ) ;
179305 return `#${ b ( hex , 1 ) } ${ b ( hex , 3 ) } ${ b ( hex , 5 ) } ` ;
180306}
307+
308+ function getZoomedAndPannedDomainLinear (
309+ min : number ,
310+ max : number ,
311+ pan : number ,
312+ zoom : number ,
313+ ) : [ number , number ] {
314+ const center = ( min + max ) / 2 + pan ;
315+ const halfExtent = ( max - min ) / ( 2 * zoom ) ;
316+ return [ center - halfExtent , center + halfExtent ] ;
317+ }
318+
319+ function getZoomedAndPannedDomainLog (
320+ min : number ,
321+ max : number ,
322+ pan : number ,
323+ zoom : number ,
324+ ) : [ number , number ] {
325+ const logMin = Math . log10 ( min ) ;
326+ const logMax = Math . log10 ( max ) ;
327+
328+ const logCenter = ( logMin + logMax ) / 2 + pan ;
329+ const halfExtent = ( logMax - logMin ) / ( 2 * zoom ) ;
330+
331+ const newLogMin = logCenter - halfExtent ;
332+ const newLogMax = logCenter + halfExtent ;
333+
334+ return [ 10 ** newLogMin , 10 ** newLogMax ] ;
335+ }
0 commit comments