@@ -2,23 +2,111 @@ import { ChangeEvent, useEffect, useState } from 'react';
22import { createRoot } from 'react-dom/client' ;
33
44import { GraphViewer } from '../src/GraphViewer.js' ;
5- import { emptyIonJSON , migrate , type IonJSON , type MIRBlock , type SampleCounts } from '../src/iongraph.js' ;
5+ import { migrate , type IonJSON , type Func , type MIRBlock , type SampleCounts } from '../src/iongraph.js' ;
6+ import { assert } from '../src/utils.js' ;
67
78export function renderWebUI ( root : HTMLElement ) {
89 const reactRoot = createRoot ( root ) ;
9- reactRoot . render ( < TestViewer /> ) ;
10+ reactRoot . render ( < WebUI /> ) ;
1011}
1112
12- export function renderGraphOnly ( root : HTMLElement , ionjson : { } ) {
13+ export function renderStandaloneUI ( root : HTMLElement , ionjson : { } ) {
1314 const reactRoot = createRoot ( root ) ;
1415 const migrated = migrate ( ionjson ) ;
15- reactRoot . render ( < GraphViewer func = { migrated . functions [ 0 ] } /> ) ;
16+ reactRoot . render ( < StandaloneUI ionjson = { migrated } /> ) ;
1617}
1718
18- function TestViewer ( ) {
19- const searchParams = new URL ( window . location . toString ( ) ) . searchParams ;
19+ const searchParams = new URL ( window . location . toString ( ) ) . searchParams ;
2020
21- const [ [ ionjson , rawIonJSON ] , setIonJSON ] = useState < readonly [ IonJSON , string ] > ( [ emptyIonJSON , JSON . stringify ( emptyIonJSON ) ] ) ;
21+ const initialFuncIndex = searchParams . has ( "func" ) ? parseInt ( searchParams . get ( "func" ) ! , 10 ) : undefined ;
22+ const initialPass = searchParams . has ( "pass" ) ? parseInt ( searchParams . get ( "pass" ) ! , 10 ) : undefined ;
23+
24+ interface MenuBarProps {
25+ browse ?: boolean ,
26+ export ?: boolean ,
27+ ionjson ?: IonJSON ,
28+
29+ funcSelected : ( func : Func | null ) => void ,
30+ }
31+
32+ function MenuBar ( props : MenuBarProps ) {
33+ const [ [ ionjson , rawIonJSON ] , setIonJSON ] = useState < readonly [ IonJSON | null , string ] > (
34+ props . ionjson
35+ ? [ props . ionjson , JSON . stringify ( props . ionjson ) ]
36+ : [ null , "" ]
37+ ) ;
38+ const [ funcIndex , setFuncIndex ] = useState < number > ( initialFuncIndex ?? 0 ) ;
39+
40+ // One-time initializer
41+ useEffect ( ( ) => {
42+ // Trigger funcSelected with any initial ion JSON
43+ if ( ionjson ) {
44+ props . funcSelected ( ionjson . functions [ funcIndex ] ?? null ) ;
45+ }
46+ } , [ ] ) ;
47+
48+ // Update ionjson if the prop changes.
49+ useEffect ( ( ) => {
50+ if ( props . ionjson ) {
51+ setIonJSON ( [ props . ionjson , JSON . stringify ( props . ionjson ) ] ) ;
52+ props . funcSelected ( props . ionjson . functions [ funcIndex ] ?? null ) ;
53+ }
54+ } , [ props . ionjson ] ) ;
55+
56+ // Notify when the func index changes.
57+ useEffect ( ( ) => {
58+ if ( ionjson ) {
59+ props . funcSelected ( ionjson . functions [ funcIndex ] ?? null ) ;
60+ }
61+ } , [ funcIndex ] ) ;
62+
63+ async function fileSelected ( e : ChangeEvent < HTMLInputElement > ) {
64+ const input = e . target ;
65+ if ( ! input . files ?. length ) {
66+ return ;
67+ }
68+
69+ const file = input . files [ 0 ] ;
70+ const newJSON = JSON . parse ( await file . text ( ) ) ;
71+ const migrated = migrate ( newJSON ) ;
72+ setIonJSON ( [ migrated , JSON . stringify ( migrated ) ] ) ;
73+ setFuncIndex ( 0 ) ;
74+ props . funcSelected ( migrated . functions [ 0 ] ?? null ) ;
75+ }
76+
77+ const numFunctions = ionjson ?. functions . length ?? 0 ;
78+ const funcIndexValid = 0 <= funcIndex && funcIndex < numFunctions ;
79+
80+ return < div className = "ig-bb ig-pv2 ig-ph3 ig-flex ig-g2 ig-items-center ig-bg-white" >
81+ { props . browse && < div >
82+ < input type = "file" onChange = { fileSelected } />
83+ </ div > }
84+ { ( ionjson ?. functions . length ?? 0 ) > 1 && < div >
85+ Function < input
86+ type = "number"
87+ value = { funcIndex }
88+ onChange = { e => {
89+ const newFuncIndex = Math . max ( 0 , Math . min ( numFunctions - 1 , parseInt ( e . target . value , 10 ) ) ) ;
90+ setFuncIndex ( isNaN ( newFuncIndex ) ? 0 : newFuncIndex ) ;
91+ } }
92+ />
93+ </ div > }
94+ < div > { ionjson ?. functions [ funcIndex ] . name ?? "" } </ div >
95+ < div className = "ig-flex-grow-1" > </ div >
96+ { props . export && < div >
97+ < button
98+ disabled = { ! funcIndexValid }
99+ onClick = { ( ) => {
100+ exportStandalone ( ionjson ?. functions [ funcIndex ] . name ?? "" , rawIonJSON , { funcIndex : funcIndex } ) ;
101+ } }
102+ > Export</ button >
103+ </ div > }
104+ </ div > ;
105+ }
106+
107+ function WebUI ( ) {
108+ const [ initialIonJSON , setInitialIonJSON ] = useState < IonJSON | undefined > ( ) ;
109+ const [ func , setFunc ] = useState < Func | null > ( null ) ;
22110 const [ sampleCounts , setSampleCounts ] = useState < SampleCounts | undefined > ( ) ;
23111
24112 useEffect ( ( ) => {
@@ -36,10 +124,9 @@ function TestViewer() {
36124 migrated = migrate ( { functions : [ json ] } ) ;
37125 }
38126
39- setIonJSON ( [ migrated , JSON . stringify ( migrated ) ] ) ;
127+ setInitialIonJSON ( migrated ) ;
40128 }
41129 } ) ( ) ;
42-
43130 ( async ( ) => {
44131 const sampleCountsFile = searchParams . get ( "sampleCounts" ) ;
45132 if ( sampleCountsFile ) {
@@ -53,92 +140,52 @@ function TestViewer() {
53140 } ) ( ) ;
54141 } , [ ] ) ;
55142
56- const [ func , setFunc ] = useState ( searchParams . has ( "func" ) ? parseInt ( searchParams . get ( "func" ) ! , 10 ) : 0 ) ;
57- const [ pass , setPass ] = useState ( searchParams . has ( "pass" ) ? parseInt ( searchParams . get ( "pass" ) ! , 10 ) : 0 ) ;
58-
59- async function fileSelected ( e : ChangeEvent < HTMLInputElement > ) {
60- const input = e . target ;
61- if ( ! input . files ?. length ) {
62- setIonJSON ( [ emptyIonJSON , JSON . stringify ( emptyIonJSON ) ] ) ;
63- return ;
143+ return < div className = "ig-absolute ig-absolute-fill ig-flex ig-flex-column" >
144+ < MenuBar browse export ionjson = { initialIonJSON } funcSelected = { f => setFunc ( f ) } />
145+ {
146+ func && < div className = "ig-relative ig-flex-basis-0 ig-flex-grow-1 ig-overflow-hidden" >
147+ < GraphViewer
148+ func = { func }
149+ pass = { initialPass }
150+ sampleCounts = { sampleCounts }
151+ />
152+ </ div >
64153 }
154+ </ div > ;
155+ }
65156
66- const file = input . files [ 0 ] ;
67- const newJSON = JSON . parse ( await file . text ( ) ) ;
68- const migrated = migrate ( newJSON ) ;
69- setIonJSON ( [ migrated , JSON . stringify ( migrated ) ] ) ;
70- }
71-
72- let blocks : MIRBlock [ ] = [ ] ;
73- const funcValid = 0 <= func && func < ionjson . functions . length ;
74- const passes = funcValid ? ionjson . functions [ func ] . passes : [ ] ;
75- const passValid = 0 <= pass && pass < passes . length ;
76- if ( funcValid && passValid ) {
77- blocks = passes [ pass ] . mir . blocks ;
78- }
157+ interface StandaloneUIProps {
158+ ionjson : IonJSON ,
159+ }
79160
161+ function StandaloneUI ( props : StandaloneUIProps ) {
162+ const [ func , setFunc ] = useState < Func | null > ( null ) ;
80163 return < div className = "ig-absolute ig-absolute-fill ig-flex ig-flex-column" >
81- < div className = "ig-bb ig-pv2 ig-ph3 ig-flex ig-g2 ig-items-center ig-bg-white" >
82- < div >
83- < input type = "file" onChange = { fileSelected } />
84- </ div >
85- { funcValid && passValid && < >
86- < div >
87- Function < input
88- type = "number"
89- value = { func }
90- onChange = { e => {
91- const newFunc = parseInt ( e . target . value , 10 ) ;
92- if ( 0 <= newFunc && newFunc < ionjson . functions . length ) {
93- setFunc ( newFunc ) ;
94- }
95- } }
96- />
97- </ div >
98- < div >
99- Pass: < input
100- type = "number"
101- value = { pass }
102- onChange = { e => {
103- const newPass = parseInt ( e . target . value , 10 ) ;
104- if ( 0 <= newPass && newPass < ionjson . functions [ func ] . passes . length ) {
105- setPass ( newPass ) ;
106- }
107- } }
108- />
109- </ div >
110- < div > { ionjson . functions [ func ] . name } </ div >
111- < div className = "ig-flex-grow-1" > </ div >
112- < div >
113- < button onClick = { ( ) => exportStandalone ( ionjson . functions [ func ] . name , rawIonJSON , { func } ) } > Export</ button >
114- </ div >
115- </ > }
116- </ div >
164+ < MenuBar ionjson = { props . ionjson } funcSelected = { f => setFunc ( f ) } />
117165 {
118- funcValid && passValid && < div className = "ig-relative ig-flex-basis-0 ig-flex-grow-1 ig-overflow-hidden" >
166+ func && < div className = "ig-relative ig-flex-basis-0 ig-flex-grow-1 ig-overflow-hidden" >
119167 < GraphViewer
120- func = { ionjson . functions [ func ] }
121- pass = { pass }
122- sampleCounts = { sampleCounts }
168+ func = { func }
169+ pass = { initialPass }
123170 />
124171 </ div >
125172 }
126- </ div > ;
173+ </ div > ;
127174}
128175
129176interface ExportOptions {
130- func ?: number ,
177+ funcIndex ?: number ,
131178}
132179
133180async function exportStandalone ( name : string , rawIonJSON : string , opts : ExportOptions = { } ) {
134181 let jsonString = rawIonJSON ;
135- if ( opts . func !== undefined ) {
182+ if ( opts . funcIndex !== undefined ) {
136183 // HACK: Because the iongraph code actually mutates the input ion JSON, we
137184 // can't just JSON.stringify it any more, so we have to take the raw JSON
138185 // from the start of the whole process, re-parse it, filter it, and then
139186 // generate new raw JSON to write to the file!
140187 const parsedIonJSON = JSON . parse ( rawIonJSON ) ;
141- const func = parsedIonJSON . functions [ opts . func ] ;
188+ const func = parsedIonJSON . functions [ opts . funcIndex ] ;
142189 const filteredIonJSON : IonJSON = { version : 1 , functions : [ func ] } ;
143190 jsonString = JSON . stringify ( filteredIonJSON ) ;
144191 }
0 commit comments