From 4582df0a3763965f304a322b583c202f2731c682 Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Thu, 8 Sep 2022 14:30:55 -0700 Subject: [PATCH 01/24] fix: allow modelFiles to be empty (useful for writing tests) --- packages/build/src/build/impl/gen-model.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/build/src/build/impl/gen-model.ts b/packages/build/src/build/impl/gen-model.ts index 09e01f53..649ba7bd 100644 --- a/packages/build/src/build/impl/gen-model.ts +++ b/packages/build/src/build/impl/gen-model.ts @@ -18,12 +18,17 @@ import type { Plugin } from '../../plugin/plugin' * - `postGenerateC` */ export async function generateModel(context: BuildContext, plugins: Plugin[]): Promise { + const config = context.config + if (config.modelFiles.length === 0) { + log('info', 'No model input files specified, skipping model generation steps') + return + } + log('info', 'Generating model...') const t0 = performance.now() // Use the defined prep directory - const config = context.config const prepDir = config.prepDir // TODO: For now we assume the path is to the `main.js` file in the cli package; @@ -37,10 +42,7 @@ export async function generateModel(context: BuildContext, plugins: Plugin[]): P await plugin.preProcessMdl(context) } } - if (config.modelFiles.length === 0) { - // Require at least one input file - throw new Error('No model input files specified') - } else if (config.modelFiles.length === 1) { + if (config.modelFiles.length === 1) { // Preprocess the single mdl file await preprocessMdl(context, sdeCmdPath, prepDir, config.modelFiles[0]) } else { From 5c0ee574be449abb391d264420a5f553605584a8 Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Thu, 8 Sep 2022 14:36:51 -0700 Subject: [PATCH 02/24] feat: checkpoint work on new plugin-config-csv package and new sir example project --- examples/sir/.gitignore | 2 + examples/sir/config/graphs.csv | 1 + examples/sir/config/inputs.csv | 1 + examples/sir/config/model.csv | 2 + examples/sir/config/strings.csv | 2 + examples/sir/model/sir.mdl | 217 +++++++++++++++++ examples/sir/package.json | 18 ++ examples/sir/packages/sir-app/index.html | 26 ++ examples/sir/packages/sir-app/package.json | 23 ++ examples/sir/packages/sir-app/src/index.css | 3 + examples/sir/packages/sir-app/src/index.js | 227 ++++++++++++++++++ examples/sir/packages/sir-app/vite.config.js | 64 +++++ examples/sir/packages/sir-core/package.json | 23 ++ examples/sir/packages/sir-core/src/index.js | 18 ++ examples/sir/packages/sir-core/vite.config.js | 1 + examples/sir/sde.config.js | 52 ++++ packages/plugin-config-csv/.eslintignore | 1 + packages/plugin-config-csv/.eslintrc.cjs | 3 + packages/plugin-config-csv/.gitignore | 2 + packages/plugin-config-csv/.prettierignore | 3 + packages/plugin-config-csv/LICENSE | 21 ++ packages/plugin-config-csv/README.md | 12 + packages/plugin-config-csv/package.json | 56 +++++ .../src/__tests__/config1/graphs.csv | 1 + .../src/__tests__/config1/inputs.csv | 1 + .../src/__tests__/config1/model.csv | 2 + .../src/__tests__/config1/strings.csv | 3 + packages/plugin-config-csv/src/context.ts | 127 ++++++++++ .../plugin-config-csv/src/gen-model-spec.ts | 28 +++ packages/plugin-config-csv/src/index.ts | 3 + .../plugin-config-csv/src/processor.spec.ts | 123 ++++++++++ packages/plugin-config-csv/src/processor.ts | 156 ++++++++++++ packages/plugin-config-csv/src/read-config.ts | 17 ++ packages/plugin-config-csv/src/var-names.ts | 49 ++++ packages/plugin-config-csv/tsconfig-base.json | 17 ++ .../plugin-config-csv/tsconfig-build.json | 6 + packages/plugin-config-csv/tsconfig-test.json | 5 + packages/plugin-config-csv/tsconfig.json | 6 + packages/plugin-config-csv/tsup.config.ts | 11 + pnpm-lock.yaml | 117 +++++++++ pnpm-workspace.yaml | 2 +- 41 files changed, 1451 insertions(+), 1 deletion(-) create mode 100644 examples/sir/.gitignore create mode 100644 examples/sir/config/graphs.csv create mode 100644 examples/sir/config/inputs.csv create mode 100644 examples/sir/config/model.csv create mode 100644 examples/sir/config/strings.csv create mode 100755 examples/sir/model/sir.mdl create mode 100644 examples/sir/package.json create mode 100644 examples/sir/packages/sir-app/index.html create mode 100644 examples/sir/packages/sir-app/package.json create mode 100644 examples/sir/packages/sir-app/src/index.css create mode 100644 examples/sir/packages/sir-app/src/index.js create mode 100644 examples/sir/packages/sir-app/vite.config.js create mode 100644 examples/sir/packages/sir-core/package.json create mode 100644 examples/sir/packages/sir-core/src/index.js create mode 100644 examples/sir/packages/sir-core/vite.config.js create mode 100644 examples/sir/sde.config.js create mode 100644 packages/plugin-config-csv/.eslintignore create mode 100644 packages/plugin-config-csv/.eslintrc.cjs create mode 100644 packages/plugin-config-csv/.gitignore create mode 100644 packages/plugin-config-csv/.prettierignore create mode 100644 packages/plugin-config-csv/LICENSE create mode 100644 packages/plugin-config-csv/README.md create mode 100644 packages/plugin-config-csv/package.json create mode 100644 packages/plugin-config-csv/src/__tests__/config1/graphs.csv create mode 100644 packages/plugin-config-csv/src/__tests__/config1/inputs.csv create mode 100644 packages/plugin-config-csv/src/__tests__/config1/model.csv create mode 100644 packages/plugin-config-csv/src/__tests__/config1/strings.csv create mode 100644 packages/plugin-config-csv/src/context.ts create mode 100644 packages/plugin-config-csv/src/gen-model-spec.ts create mode 100644 packages/plugin-config-csv/src/index.ts create mode 100644 packages/plugin-config-csv/src/processor.spec.ts create mode 100644 packages/plugin-config-csv/src/processor.ts create mode 100644 packages/plugin-config-csv/src/read-config.ts create mode 100644 packages/plugin-config-csv/src/var-names.ts create mode 100644 packages/plugin-config-csv/tsconfig-base.json create mode 100644 packages/plugin-config-csv/tsconfig-build.json create mode 100644 packages/plugin-config-csv/tsconfig-test.json create mode 100644 packages/plugin-config-csv/tsconfig.json create mode 100644 packages/plugin-config-csv/tsup.config.ts diff --git a/examples/sir/.gitignore b/examples/sir/.gitignore new file mode 100644 index 00000000..3aa52f1b --- /dev/null +++ b/examples/sir/.gitignore @@ -0,0 +1,2 @@ +sde-prep +**/generated diff --git a/examples/sir/config/graphs.csv b/examples/sir/config/graphs.csv new file mode 100644 index 00000000..1d20bde6 --- /dev/null +++ b/examples/sir/config/graphs.csv @@ -0,0 +1 @@ +id,side,parent menu,graph title,menu title,mini title,vensim graph,kind,modes,units,alternate,unused 1,unused 2,unused 3,x axis min,x axis max,x axis label,unused 4,unused 5,y axis min,y axis max,y axis soft max,y axis label,y axis format,unused 6,unused 7,plot 1 variable,plot 1 source,plot 1 style,plot 1 label,plot 1 color,plot 1 unused 1,plot 1 unused 2,plot 2 variable,plot 2 source,plot 2 style,plot 2 label,plot 2 color,plot 2 unused 1,plot 2 unused 2,plot 3 variable,plot 3 source,plot 3 style,plot 3 label,plot 3 color,plot 3 unused 1,plot 3 unused 2,plot 4 variable,plot 4 source,plot 4 style,plot 4 label,plot 4 color,plot 4 unused 1,plot 4 unused 2,plot 5 variable,plot 5 source,plot 5 style,plot 5 label,plot 5 color,plot 5 unused 1,plot 5 unused 2,plot 6 variable,plot 6 source,plot 6 style,plot 6 label,plot 6 color,plot 6 unused 1,plot 6 unused 2,plot 7 variable,plot 7 source,plot 7 style,plot 7 label,plot 7 color,plot 7 unused 1,plot 7 unused 2,plot 8 variable,plot 8 source,plot 8 style,plot 8 label,plot 8 color,plot 8 unused 1,plot 8 unused 2,plot 9 variable,plot 9 source,plot 9 style,plot 9 label,plot 9 color,plot 9 unused 1,plot 9 unused 2,plot 10 variable,plot 10 source,plot 10 style,plot 10 label,plot 10 color,plot 10 unused 1,plot 10 unused 2,plot 11 variable,plot 11 source,plot 11 style,plot 11 label,plot 11 color,plot 11 unused 1,plot 11 unused 2,plot 12 variable,plot 12 source,plot 12 style,plot 12 label,plot 12 color,plot 12 unused 1,plot 12 unused 2,plot 13 variable,plot 13 source,plot 13 style,plot 13 label,plot 13 color,plot 13 unused 1,plot 13 unused 2,plot 14 variable,plot 14 source,plot 14 style,plot 14 label,plot 14 color,plot 14 unused 1,plot 14 unused 2,plot 15 variable,plot 15 source,plot 15 style,plot 15 label,plot 15 color,plot 15 unused 1,plot 15 unused 2 diff --git a/examples/sir/config/inputs.csv b/examples/sir/config/inputs.csv new file mode 100644 index 00000000..3c41d920 --- /dev/null +++ b/examples/sir/config/inputs.csv @@ -0,0 +1 @@ +id,input type,viewid,varname,label,view level,group name,slider min,slider max,slider/switch default,slider step,units,format,reversed,range 2 start,range 3 start,range 4 start,range 5 start,range 1 label,range 2 label,range 3 label,range 4 label,range 5 label,enabled value,disabled value,controlled input ids,listing label diff --git a/examples/sir/config/model.csv b/examples/sir/config/model.csv new file mode 100644 index 00000000..5f2246ff --- /dev/null +++ b/examples/sir/config/model.csv @@ -0,0 +1,2 @@ +startTime,endTime +0,200 diff --git a/examples/sir/config/strings.csv b/examples/sir/config/strings.csv new file mode 100644 index 00000000..f0f416ca --- /dev/null +++ b/examples/sir/config/strings.csv @@ -0,0 +1,2 @@ +id,string +__model_name,SIR diff --git a/examples/sir/model/sir.mdl b/examples/sir/model/sir.mdl new file mode 100755 index 00000000..bb4fb9b9 --- /dev/null +++ b/examples/sir/model/sir.mdl @@ -0,0 +1,217 @@ +{UTF-8} +******************************************************** + .SIR-Model +********************************************************~ + + The SIR Model of Infectious Disease + John Sterman (1999) Business Dynamics. Irwin/McGraw-Hill + Copyright (c) 1999 John Sterman + + This is the classic SIR (Susceptible-Infectious-Recovered) model of infectious \ + disease. Infectious individuals remain infectious for a constant average + period, then recover. + + In this version, the contact rate can be set to increase linearly, + and the population is challenged by the arrival of a single infectious + individual every 50 days. Illustrates herd immunity. Chapter 9. + | + +Infectious Population I= INTEG ( + Infection Rate-Recovery Rate, + 1) + ~ People + ~ The infectious population accumulates the infection rate and the \ + inmigration of infectious rate less the recovery rate. + | + +Initial Contact Rate= + 2.5 + ~ 1/Day + ~ The initial contact rate; the actual contact rate rises at a slope \ + determined by the user. + | + +Contact Rate c= + Initial Contact Rate + ~ 1/Day + ~ People in the community interact at a certain rate (the Contact Rate, c, \ + measured in people contacted per person per time period, or 1/time \ + periods). The contact rate rises at the Ramp Slope starting in day 1. + | + +Reproduction Rate= + Contact Rate c*Infectivity i*Average Duration of Illness d*Susceptible Population S/\ + Total Population P + ~ Dimensionless + ~ | + +Total Population P= + 10000 + ~ People + ~ The total population is constant + | + +Infection Rate= + Contact Rate c*Infectivity i*Susceptible Population S*Infectious Population I/Total Population P + ~ People/Day + ~ The infection rate is the total number of encounters Sc multiplied by the \ + probability that any of those encounters is with an infectious individual \ + I/N, and finally multiplied by the probability that an encounter with an \ + infectious person results in infection i. + | + +Average Duration of Illness d= + 2 + ~ Day + ~ The average length of time that a person is infectious. + | + +Recovered Population R= INTEG ( + Recovery Rate, + 0) + ~ People + ~ The recovered population R accumulates the recovery rate + | + +Recovery Rate= + Infectious Population I/Average Duration of Illness d + ~ People/Day + ~ The rate at which the infected population recover and become immune to the \ + infection. + | + +Infectivity i= + 0.25 + ~ Dimensionless + ~ The infectivity (i) of the disease is the probability that a person will \ + become infected after exposure to someone with the disease. + | + +Susceptible Population S= INTEG ( + -Infection Rate, + Total Population P - Infectious Population I - Recovered Population R) + ~ People + ~ The susceptible population, as in the simple logistic epidemic model, is \ + reduced by the infection rate. The initial susceptible population is the \ + total population less the initial number of infectives and any initially \ + recovered individuals. + | + +******************************************************** + .Control +********************************************************~ + Simulation Control Paramaters + | + +FINAL TIME = 200 + ~ Day + ~ The final time for the simulation. + | + +INITIAL TIME = 0 + ~ Day + ~ The initial time for the simulation. + | + +SAVEPER = 2 + ~ Day + ~ The frequency with which output is stored. + | + +TIME STEP = 0.0625 + ~ Day + ~ The time step for the simulation. + | + +\\\---/// Sketch information - do not modify anything except names +V300 Do not put anything below this section - it will be ignored +*View 1 +$192-192-192,0,Helvetica|10|B|0-0-0|0-0-0|0-0-0|-1--1--1|-1--1--1|96,96,100,0 +10,1,Susceptible Population S,162,192,40,20,3,3,0,0,0,0,0,0 +10,2,Infectious Population I,428,190,40,20,3,3,0,0,0,0,0,0 +1,3,5,2,4,0,0,22,0,0,0,-1--1--1,,1|(344,191)| +1,4,5,1,100,0,0,22,0,0,0,-1--1--1,,1|(245,191)| +11,5,444,295,191,6,8,34,3,0,0,1,0,0,0 +10,6,Infection Rate,295,228,40,29,40,3,0,0,-1,0,0,0 +1,7,1,6,1,0,43,0,0,64,0,-1--1--1,,1|(214,259)| +1,8,2,6,1,0,43,0,0,64,0,-1--1--1,,1|(389,256)| +10,9,Infectivity i,394,326,34,15,8,3,0,0,0,0,0,0 +10,10,Contact Rate c,168,300,31,19,8,3,0,0,0,0,0,0 +1,11,10,6,1,0,43,0,0,192,0,-1--1--1,,1|(269,270)| +1,12,9,6,1,0,43,0,0,64,0,-1--1--1,,1|(313,268)| +12,13,0,232,218,15,15,5,4,0,0,-1,0,0,0 +B +12,14,0,365,216,15,15,4,4,0,0,-1,0,0,0 +R +10,15,Recovered Population R,672,190,40,20,3,3,0,0,0,0,0,0 +1,16,18,15,4,0,0,22,0,0,0,-1--1--1,,1|(594,190)| +1,17,18,2,100,0,0,22,0,0,0,-1--1--1,,1|(506,190)| +11,18,492,550,190,6,8,34,3,0,0,1,0,0,0 +10,19,Recovery Rate,550,221,33,23,40,3,0,0,-1,0,0,0 +1,20,2,19,1,0,43,0,0,192,0,-1--1--1,,1|(484,256)| +10,21,Average Duration of Illness d,571,316,40,24,8,3,0,0,0,0,0,0 +1,22,21,19,1,0,45,0,0,192,0,-1--1--1,,1|(593,264)| +12,23,0,493,212,15,15,5,4,0,0,-1,0,0,0 +B +10,24,Total Population P,257,325,40,20,8,3,0,0,0,0,0,0 +1,25,24,6,1,0,45,0,0,192,0,-1--1--1,,1|(292,277)| +10,26,Reproduction Rate,383,472,51,21,8,3,0,0,0,0,0,0 +10,27,Contact Rate c,249,523,40,20,8,2,0,3,-1,0,0,0,128-128-128,0-0-0,|12|B|128-128-128 +10,28,Total Population P,374,574,40,20,8,2,0,3,-1,0,0,0,128-128-128,0-0-0,|12|B|128-128-128 +10,29,Infectivity i,504,526,50,18,8,2,0,3,-1,0,0,0,128-128-128,0-0-0,|12|B|128-128-128 +10,30,Average Duration of Illness d,236,444,40,20,8,2,0,3,-1,0,0,0,128-128-128,0-0-0,|12|B|128-128-128 +10,31,Susceptible Population S,530,449,49,29,8,2,0,3,-1,0,0,0,128-128-128,0-0-0,|12|B|128-128-128 +1,32,30,26,0,0,43,0,0,64,0,-1--1--1,,1|(297,455)| +1,33,27,26,0,0,43,0,0,64,0,-1--1--1,,1|(303,502)| +1,34,29,26,0,0,43,0,0,64,0,-1--1--1,,1|(453,503)| +1,35,31,26,0,0,43,0,0,64,0,-1--1--1,,1|(464,458)| +1,36,28,26,0,0,45,0,0,64,0,-1--1--1,,1|(377,530)| +12,37,0,232,238,29,9,8,4,0,8,-1,0,0,0,0-0-0,0-0-0,|8|B|0-0-0 +Depletion +12,38,0,365,240,30,8,8,4,0,8,-1,0,0,0,0-0-0,0-0-0,|8|B|0-0-0 +Contagion +12,39,0,496,235,29,9,8,4,0,8,-1,0,0,0,0-0-0,0-0-0,|8|B|0-0-0 +Recovery +10,40,Initial Contact Rate,58,364,50,25,8,3,0,0,0,0,0,0 +1,41,40,10,1,0,43,0,0,192,0,-1--1--1,,1|(112,348)| +1,42,2,1,0,0,0,0,0,64,1,-1--1--1,,1|(301,190)| +1,43,15,1,0,0,0,0,0,64,1,-1--1--1,,1|(423,190)| +1,44,24,1,0,0,0,0,0,64,1,-1--1--1,,1|(213,264)| +///---\\\ +:L<%^E!@ +1:sir.vdf +9:sir +15:0,0,0,0,0,0 +19:100,0 +27:2, +34:0, +4:Time +5:Reproduction Rate +35:Date +36:YYYY-MM-DD +37:2000 +38:1 +39:1 +40:2 +41:0 +42:0 +24:0 +25:200 +26:200 +57:1 +54:0 +55:0 +59:0 +56:0 +58:0 +44:65001 +46:0 +45:0 +49:0 +50:0 +51: +52: +53: +43:sir +47:sir +48: diff --git a/examples/sir/package.json b/examples/sir/package.json new file mode 100644 index 00000000..d7ff5e08 --- /dev/null +++ b/examples/sir/package.json @@ -0,0 +1,18 @@ +{ + "name": "sir", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "sde bundle", + "dev": "sde dev" + }, + "dependencies": { + "@sdeverywhere/cli": "^0.7.0", + "@sdeverywhere/plugin-check": "^0.1.0", + "@sdeverywhere/plugin-config-csv": "^0.1.0", + "@sdeverywhere/plugin-vite": "^0.1.1", + "@sdeverywhere/plugin-wasm": "^0.1.0", + "@sdeverywhere/plugin-worker": "^0.1.0" + } +} diff --git a/examples/sir/packages/sir-app/index.html b/examples/sir/packages/sir-app/index.html new file mode 100644 index 00000000..2dc1ce72 --- /dev/null +++ b/examples/sir/packages/sir-app/index.html @@ -0,0 +1,26 @@ + + + + SIR + + + + + + + + + +
+ +
+ +
+
+ +
+
HI
+
+
+ + diff --git a/examples/sir/packages/sir-app/package.json b/examples/sir/packages/sir-app/package.json new file mode 100644 index 00000000..f549aeea --- /dev/null +++ b/examples/sir/packages/sir-app/package.json @@ -0,0 +1,23 @@ +{ + "name": "sir-app", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "clean": "rm -rf public", + "lint": "eslint src --max-warnings 0", + "prettier:check": "prettier --check .", + "prettier:fix": "prettier --write .", + "precommit": "../scripts/precommit", + "build": "vite build", + "dev": "vite" + }, + "dependencies": { + "bootstrap-slider": "10.6.2", + "chart.js": "^2.9.4", + "jquery": "^3.5.1" + }, + "devDependencies": { + "vite": "^2.9.12" + } +} diff --git a/examples/sir/packages/sir-app/src/index.css b/examples/sir/packages/sir-app/src/index.css new file mode 100644 index 00000000..7513d508 --- /dev/null +++ b/examples/sir/packages/sir-app/src/index.css @@ -0,0 +1,3 @@ +body { + background-color: blueviolet; +} diff --git a/examples/sir/packages/sir-app/src/index.js b/examples/sir/packages/sir-app/src/index.js new file mode 100644 index 00000000..2eb73153 --- /dev/null +++ b/examples/sir/packages/sir-app/src/index.js @@ -0,0 +1,227 @@ +import $ from 'jquery' +import Slider from 'bootstrap-slider' +import 'bootstrap-slider/dist/css/bootstrap-slider.css' + +import { createModel } from '@core' +import enStrings from '@core/strings/en' + +let coreConfig +let model +let modelContext +let graphView + +/** + * Return the base (English) string for the given key. + */ +function str(key) { + return enStrings[key] +} + +/* + * INPUTS + */ + +function addSliderItem(sliderInput) { + const spec = sliderInput.spec + const inputElemId = `input-${spec.id}` + + const div = $(`
`).append([ + $(`
${str(spec.labelKey)}
`), + $(``), + $(`
${str(spec.descriptionKey)}
`) + ]) + + $('#inputs-content').append(div) + + const slider = new Slider(`#${inputElemId}`, { + value: sliderInput.get(), + min: spec.minValue, + max: spec.maxValue, + step: spec.step, + reversed: spec.reversed, + tooltip: 'hide' + }) + + // Update the model input when the slider is dragged or the track is clicked + slider.on('change', change => { + sliderInput.set(change.newValue) + }) +} + +function addSwitchItem(switchInput) { + const spec = switchInput.spec + + const inputElemId = `input-${spec.id}` + + function addCheckbox(desc) { + // Exercise for the reader: gray out and disable sliders that are inactive + // when this checkbox is checked + const div = $(`
`).append([ + $(``), + $(``), + $(`
${desc}
`) + ]) + $('#inputs-content').append(div) + $(`#${inputElemId}`).on('change', function () { + if ($(this).is(':checked')) { + switchInput.set(spec.onValue) + } else { + switchInput.set(spec.offValue) + } + }) + } + + if (!spec.slidersActiveWhenOff && spec.slidersActiveWhenOn) { + // This is a switch that controls whether the slider that follows it is active + addCheckbox('The following slider will have an effect only when this is checked.') + for (const sliderId of spec.slidersActiveWhenOn) { + const slider = modelContext.getInputForId(sliderId) + addSliderItem(slider) + } + } else { + // This is a detailed settings switch; when it's off, the sliders above it + // are active and the sliders below are inactive (and vice versa) + for (const sliderId of spec.slidersActiveWhenOff) { + const slider = modelContext.getInputForId(sliderId) + addSliderItem(slider) + } + addCheckbox( + 'When this is unchecked, only the slider above has an effect, and the ones below are inactive (and vice versa).' + ) + for (const sliderId of spec.slidersActiveWhenOn) { + const slider = modelContext.getInputForId(sliderId) + addSliderItem(slider) + } + } +} + +function showInputs() {} + +/** + * Initialize the UI for the inputs menu and panel. + */ +function initInputsUI() { + $('#inputs-content').empty() + for (const inputId of coreConfig.inputIds) { + const input = modelContext.getInputForId(inputId) + if (input.kind === 'slider') { + addSliderItem(input) + } else if (input.kind === 'switch') { + addSwitchItem(input) + } + } +} + +/* + * GRAPHS + */ + +// function createGraphViewModel(graphSpec) { +// return { +// spec: graphSpec, +// style: 'normal', +// getLineWidth: () => window.innerWidth * (0.5 / 100), +// getScaleLabelFontSize: () => window.innerWidth * (1.2 / 100), +// getAxisLabelFontSize: () => window.innerWidth * (1.0 / 100), +// getSeriesForVar: (varId, sourceName) => { +// return modelContext.getSeriesForVar(varId, sourceName) +// }, +// getStringForKey: key => { +// // TODO: Inject values if string is templated +// return str(key) +// }, +// formatYAxisTickValue: value => { +// // TODO: Can use d3-format here and pass graphSpec.yFormat +// const stringValue = value.toFixed(1) +// if (graphSpec.kind === 'h-bar' && graphSpec.id !== '142') { +// // For bar charts that display percentages, format as a percent value +// return `${stringValue}%` +// } else { +// // For all other cases, return the string value without units +// return stringValue +// } +// }, +// formatYAxisTooltipValue: value => { +// // TODO: Can use d3-format here and pass '.2~f', for example +// return value.toFixed(2) +// } +// } +// } + +function showGraph(graphSpec) { + if (graphView) { + // Destroy the old view before switching to a new one + graphView.destroy() + } + + const canvas = $('#top-graph-canvas')[0] + const viewModel = createGraphViewModel(graphSpec) + const options = { + fontFamily: 'Helvetica, sans-serif', + fontStyle: 'bold', + fontColor: '#231f20' + } + const tooltipsEnabled = true + const xAxisLabel = graphSpec.xAxisLabelKey ? str(graphSpec.xAxisLabelKey) : undefined + const yAxisLabel = graphSpec.yAxisLabelKey ? str(graphSpec.yAxisLabelKey) : undefined + graphView = new GraphView(canvas, viewModel, options, tooltipsEnabled, xAxisLabel, yAxisLabel) +} + +function addGraphItem(graphSpec) { + const title = str(graphSpec.menuTitleKey || graphSpec.titleKey) + const option = $(``).data(graphSpec) + $('#graph-selector').append(option) +} + +/** + * Initialize the UI for the graphs panel. + */ +function initGraphsUI() { + // Add the graph selector options + for (const spec of coreConfig.graphs.values()) { + addGraphItem(spec) + } + + // When a graph item is selected, show that graph + $('select').on('change', function () { + const graphId = this.value + const graphSpec = coreConfig.graphs.get(graphId) + showGraph(graphSpec) + }) + + // Select the first graph by default + showGraph(coreConfig.graphs.values().next().value) +} + +/* + * INITIALIZATION + */ + +/** + * Initialize the web app. This will load the wasm model asynchronously, + * and upon completion will initialize the user interface. + */ +async function initApp() { + // Initialize the model asynchronously + try { + model = await createModel() + modelContext = model.addContext() + } catch (e) { + console.error(`ERROR: Failed to load model: ${e.message}`) + return + } + + // Initialize the user interface + initInputsUI() + initGraphsUI() + + // When the model outputs are updated, refresh the graph + modelContext.onOutputsChanged(() => { + if (graphView) { + graphView.updateData() + } + }) +} + +// Initialize the app when this script is loaded +initApp() diff --git a/examples/sir/packages/sir-app/vite.config.js b/examples/sir/packages/sir-app/vite.config.js new file mode 100644 index 00000000..7f5e1b85 --- /dev/null +++ b/examples/sir/packages/sir-app/vite.config.js @@ -0,0 +1,64 @@ +import { dirname, resolve } from 'path' +import { fileURLToPath } from 'url' + +import { defineConfig } from 'vite' + +// Note that Vite tries to inject `__dirname` but if we leave it undefined then +// Node will complain ("ERROR: __dirname is not defined in ES module scope") so +// we use our own special name here +const appDir = dirname(fileURLToPath(import.meta.url)) + +export default defineConfig(env => { + return { + // Don't clear the screen in dev mode so that we can see builder output + clearScreen: false, + + // Use this directory as the root directory for the app project + root: appDir, + + // Use `.` as the base directory (instead of the default `/`); this controls + // how the path to the js/css files are generated in `index.html` + base: '', + + // Load static files from `static` (instead of the default `public`) + publicDir: 'static', + + // Inject special values into the generated JS + define: { + // Set a flag to indicate that this is a production build + __PRODUCTION__: env.mode === 'production' + }, + + resolve: { + alias: { + '@core': resolve(appDir, '..', 'sir-core', 'src') + } + }, + + build: { + // Write output files to `public` (instead of the default `dist`) + outDir: 'public', + + // Write js/css files to `public` (instead of the default `/assets`) + assetsDir: '', + + // TODO: Uncomment for debugging purposes + // minify: false, + + rollupOptions: { + output: { + // XXX: Prevent vite from creating a separate `vendor.js` file + manualChunks: undefined + } + } + }, + + server: { + // Run the dev server at `localhost:8091` by default + port: 8091, + + // Open the app in the browser by default + open: '/index.html' + } + } +}) diff --git a/examples/sir/packages/sir-core/package.json b/examples/sir/packages/sir-core/package.json new file mode 100644 index 00000000..38129448 --- /dev/null +++ b/examples/sir/packages/sir-core/package.json @@ -0,0 +1,23 @@ +{ + "name": "sir-core", + "version": "1.0.0", + "private": true, + "files": [ + "dist/**", + "strings/**" + ], + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "clean": "rm -rf dist", + "lint": "eslint src --max-warnings 0", + "prettier:check": "prettier --check .", + "prettier:fix": "prettier --write .", + "precommit": "../scripts/precommit" + }, + "dependencies": { + "@sdeverywhere/runtime": "^0.1.0", + "@sdeverywhere/runtime-async": "^0.1.0" + } +} diff --git a/examples/sir/packages/sir-core/src/index.js b/examples/sir/packages/sir-core/src/index.js new file mode 100644 index 00000000..cd56b93a --- /dev/null +++ b/examples/sir/packages/sir-core/src/index.js @@ -0,0 +1,18 @@ +import { ModelScheduler } from '@sdeverywhere/runtime' +import { spawnAsyncModelRunner } from '@sdeverywhere/runtime-async' +import modelWorkerJs from './generated/worker.js?raw' + +export class Model { + constructor() {} +} + +export async function createModel() { + // Initialize the wasm model asynchronously. We inline the worker code in the + // rolled-up bundle, so that we don't have to fetch a separate `worker.js` file. + const runner = await spawnAsyncModelRunner({ source: modelWorkerJs }) + + // Create the model scheduler + const scheduler = new ModelScheduler(runner, inputs, outputs) + + // TODO +} diff --git a/examples/sir/packages/sir-core/vite.config.js b/examples/sir/packages/sir-core/vite.config.js new file mode 100644 index 00000000..70b786d1 --- /dev/null +++ b/examples/sir/packages/sir-core/vite.config.js @@ -0,0 +1 @@ +// TODO diff --git a/examples/sir/sde.config.js b/examples/sir/sde.config.js new file mode 100644 index 00000000..5d609ded --- /dev/null +++ b/examples/sir/sde.config.js @@ -0,0 +1,52 @@ +import { dirname, join as joinPath } from 'path' +import { fileURLToPath } from 'url' + +import { checkPlugin } from '@sdeverywhere/plugin-check' +import { configProcessor } from '@sdeverywhere/plugin-config-csv' +import { vitePlugin } from '@sdeverywhere/plugin-vite' +import { wasmPlugin } from '@sdeverywhere/plugin-wasm' +import { workerPlugin } from '@sdeverywhere/plugin-worker' + +const baseName = 'sir' +const __dirname = dirname(fileURLToPath(import.meta.url)) +const configDir = joinPath(__dirname, 'config') +const packagePath = (...parts) => joinPath(__dirname, 'packages', ...parts) +const appPath = (...parts) => packagePath(`${baseName}-app`, ...parts) +const corePath = (...parts) => packagePath(`${baseName}-core`, ...parts) + +export async function config() { + return { + // Specify the Vensim model to read + modelFiles: ['model/sir.mdl'], + + // Read csv files from `config` directory + modelSpec: configProcessor({ + config: configDir, + out: corePath() + }), + + plugins: [ + // Generate a `wasm-model.js` file containing the Wasm model + wasmPlugin(), + + // Generate a `worker.js` file that runs the Wasm model in a worker + workerPlugin({ + outputPaths: [corePath('src', 'model', 'generated', 'worker.js')] + }), + + // Build or serve the model explorer app + vitePlugin({ + name: `${baseName}-app`, + apply: { + development: 'serve' + }, + config: { + configFile: appPath('vite.config.js') + } + }), + + // Run model check + checkPlugin() + ] + } +} diff --git a/packages/plugin-config-csv/.eslintignore b/packages/plugin-config-csv/.eslintignore new file mode 100644 index 00000000..1521c8b7 --- /dev/null +++ b/packages/plugin-config-csv/.eslintignore @@ -0,0 +1 @@ +dist diff --git a/packages/plugin-config-csv/.eslintrc.cjs b/packages/plugin-config-csv/.eslintrc.cjs new file mode 100644 index 00000000..3a98c61d --- /dev/null +++ b/packages/plugin-config-csv/.eslintrc.cjs @@ -0,0 +1,3 @@ +module.exports = { + extends: ['../../.eslintrc-ts-common.cjs'] +} diff --git a/packages/plugin-config-csv/.gitignore b/packages/plugin-config-csv/.gitignore new file mode 100644 index 00000000..5e9b0cb2 --- /dev/null +++ b/packages/plugin-config-csv/.gitignore @@ -0,0 +1,2 @@ +dist +docs/entry.md diff --git a/packages/plugin-config-csv/.prettierignore b/packages/plugin-config-csv/.prettierignore new file mode 100644 index 00000000..bfdb68de --- /dev/null +++ b/packages/plugin-config-csv/.prettierignore @@ -0,0 +1,3 @@ +dist +docs +CHANGELOG.md diff --git a/packages/plugin-config-csv/LICENSE b/packages/plugin-config-csv/LICENSE new file mode 100644 index 00000000..29bed4e9 --- /dev/null +++ b/packages/plugin-config-csv/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2022 Climate Interactive / New Venture Fund + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/plugin-config-csv/README.md b/packages/plugin-config-csv/README.md new file mode 100644 index 00000000..d78faedd --- /dev/null +++ b/packages/plugin-config-csv/README.md @@ -0,0 +1,12 @@ +# @sdeverywhere/plugin-config-csv + +This package provides a plugin that reads CSV files used to configure a library or app +around an SDEverywhere-generated system dynamics model. + +## Documentation + +TODO + +## License + +SDEverywhere is distributed under the MIT license. See `LICENSE` for more details. diff --git a/packages/plugin-config-csv/package.json b/packages/plugin-config-csv/package.json new file mode 100644 index 00000000..2e12bc91 --- /dev/null +++ b/packages/plugin-config-csv/package.json @@ -0,0 +1,56 @@ +{ + "name": "@sdeverywhere/plugin-config-csv", + "version": "0.1.0", + "files": [ + "dist/**" + ], + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "scripts": { + "clean": "rm -rf dist", + "lint": "eslint src --ext .ts --max-warnings 0", + "prettier:check": "prettier --check .", + "prettier:fix": "prettier --write .", + "precommit": "../../scripts/precommit", + "test": "vitest run", + "test:watch": "vitest", + "test:ci": "vitest run", + "type-check": "tsc --noEmit -p tsconfig-build.json", + "build": "tsup", + "ci:build": "run-s clean lint prettier:check test:ci type-check build" + }, + "dependencies": { + "@sdeverywhere/build": "^0.1.1", + "byline": "^5.0.0", + "csv-parse": "^4.15.4" + }, + "devDependencies": { + "@types/byline": "^4.2.33", + "@types/dedent": "^0.7.0", + "@types/marked": "^4.0.1", + "@types/node": "^16.11.7", + "@types/temp": "^0.9.1", + "dedent": "^0.7.0", + "temp": "^0.9.4" + }, + "author": "Climate Interactive", + "license": "MIT", + "homepage": "https://sdeverywhere.org", + "repository": { + "type": "git", + "url": "https://github.com/climateinteractive/SDEverywhere.git", + "directory": "packages/plugin-config-csv" + }, + "bugs": { + "url": "https://github.com/climateinteractive/SDEverywhere/issues" + } +} diff --git a/packages/plugin-config-csv/src/__tests__/config1/graphs.csv b/packages/plugin-config-csv/src/__tests__/config1/graphs.csv new file mode 100644 index 00000000..1d20bde6 --- /dev/null +++ b/packages/plugin-config-csv/src/__tests__/config1/graphs.csv @@ -0,0 +1 @@ +id,side,parent menu,graph title,menu title,mini title,vensim graph,kind,modes,units,alternate,unused 1,unused 2,unused 3,x axis min,x axis max,x axis label,unused 4,unused 5,y axis min,y axis max,y axis soft max,y axis label,y axis format,unused 6,unused 7,plot 1 variable,plot 1 source,plot 1 style,plot 1 label,plot 1 color,plot 1 unused 1,plot 1 unused 2,plot 2 variable,plot 2 source,plot 2 style,plot 2 label,plot 2 color,plot 2 unused 1,plot 2 unused 2,plot 3 variable,plot 3 source,plot 3 style,plot 3 label,plot 3 color,plot 3 unused 1,plot 3 unused 2,plot 4 variable,plot 4 source,plot 4 style,plot 4 label,plot 4 color,plot 4 unused 1,plot 4 unused 2,plot 5 variable,plot 5 source,plot 5 style,plot 5 label,plot 5 color,plot 5 unused 1,plot 5 unused 2,plot 6 variable,plot 6 source,plot 6 style,plot 6 label,plot 6 color,plot 6 unused 1,plot 6 unused 2,plot 7 variable,plot 7 source,plot 7 style,plot 7 label,plot 7 color,plot 7 unused 1,plot 7 unused 2,plot 8 variable,plot 8 source,plot 8 style,plot 8 label,plot 8 color,plot 8 unused 1,plot 8 unused 2,plot 9 variable,plot 9 source,plot 9 style,plot 9 label,plot 9 color,plot 9 unused 1,plot 9 unused 2,plot 10 variable,plot 10 source,plot 10 style,plot 10 label,plot 10 color,plot 10 unused 1,plot 10 unused 2,plot 11 variable,plot 11 source,plot 11 style,plot 11 label,plot 11 color,plot 11 unused 1,plot 11 unused 2,plot 12 variable,plot 12 source,plot 12 style,plot 12 label,plot 12 color,plot 12 unused 1,plot 12 unused 2,plot 13 variable,plot 13 source,plot 13 style,plot 13 label,plot 13 color,plot 13 unused 1,plot 13 unused 2,plot 14 variable,plot 14 source,plot 14 style,plot 14 label,plot 14 color,plot 14 unused 1,plot 14 unused 2,plot 15 variable,plot 15 source,plot 15 style,plot 15 label,plot 15 color,plot 15 unused 1,plot 15 unused 2 diff --git a/packages/plugin-config-csv/src/__tests__/config1/inputs.csv b/packages/plugin-config-csv/src/__tests__/config1/inputs.csv new file mode 100644 index 00000000..3c41d920 --- /dev/null +++ b/packages/plugin-config-csv/src/__tests__/config1/inputs.csv @@ -0,0 +1 @@ +id,input type,viewid,varname,label,view level,group name,slider min,slider max,slider/switch default,slider step,units,format,reversed,range 2 start,range 3 start,range 4 start,range 5 start,range 1 label,range 2 label,range 3 label,range 4 label,range 5 label,enabled value,disabled value,controlled input ids,listing label diff --git a/packages/plugin-config-csv/src/__tests__/config1/model.csv b/packages/plugin-config-csv/src/__tests__/config1/model.csv new file mode 100644 index 00000000..6e386d87 --- /dev/null +++ b/packages/plugin-config-csv/src/__tests__/config1/model.csv @@ -0,0 +1,2 @@ +startTime,endTime +100,200 diff --git a/packages/plugin-config-csv/src/__tests__/config1/strings.csv b/packages/plugin-config-csv/src/__tests__/config1/strings.csv new file mode 100644 index 00000000..75dc1672 --- /dev/null +++ b/packages/plugin-config-csv/src/__tests__/config1/strings.csv @@ -0,0 +1,3 @@ +id,string +__string_1,String 1 +__string_2,String 2 diff --git a/packages/plugin-config-csv/src/context.ts b/packages/plugin-config-csv/src/context.ts new file mode 100644 index 00000000..908eb4be --- /dev/null +++ b/packages/plugin-config-csv/src/context.ts @@ -0,0 +1,127 @@ +// Copyright (c) 2022 Climate Interactive / New Venture Fund + +import { readFileSync } from 'fs' +import { join as joinPath } from 'path' + +import parseCsv from 'csv-parse/lib/sync.js' + +import type { BuildContext, InputSpec, LogLevel, OutputSpec } from '@sdeverywhere/build' + +import type { InputVarId, OutputVarId } from './var-names' + +export type CsvRow = { [key: string]: string } + +export class ConfigContext { + private readonly inputSpecs: Map = new Map() + private readonly outputVarNames: Map = new Map() + + constructor( + private readonly buildContext: BuildContext, + public readonly modelStartTime: number, + public readonly modelEndTime: number + ) {} + + /** + * Log a message to the console and/or the in-browser overlay panel. + * + * @param level The log level (verbose, info, error). + * @param msg The message. + */ + log(level: LogLevel, msg: string): void { + this.buildContext.log(level, msg) + } + + /** + * Write a file to the staged directory. + * + * This file will be copied (along with other staged files) into the destination + * directory only after the build process has completed. Copying all staged files + * at once helps improve the local development experience by making it so that + * live reloading tools only need to refresh once instead of every time a build + * file is written. + * + * @param srcDir The directory underneath the configured `staged` directory where + * the file will be written (this must be a relative path). + * @param dstDir The absolute path to the destination directory where the staged + * file will be copied when the build has completed. + * @param filename The name of the file. + * @param content The file content. + */ + writeStagedFile(srcDir: string, dstDir: string, filename: string, content: string): void { + this.buildContext.writeStagedFile(srcDir, dstDir, filename, content) + } + + getOrderedInputs(): InputSpec[] { + // TODO: It would be nice to alphabetize the inputs, but currently we have + // code that assumes that the InputSpecs in the map have the same order + // as the variables in the spec file and model config, so preserve the + // existing order here for now + return Array.from(this.inputSpecs.values()) + } + + getOrderedOutputs(): OutputSpec[] { + // Sort the output variable names alphabetically + const alphabetical = (a: string, b: string) => (a > b ? 1 : b > a ? -1 : 0) + const varNames = Array.from(this.outputVarNames.values()).sort(alphabetical) + return varNames.map(varName => { + return { + varName + } + }) + } + + writeStringsFiles(): void { + // const dstDir = corePackageFilePath('strings') + // this.strings.writeJsFiles(this.buildContext, dstDir, xlatLangs) + } +} + +export function createConfigContext(buildContext: BuildContext, configDir: string): ConfigContext { + // Read basic app configuration from `model.csv` + const modelCsv = readConfigCsvFile(configDir, 'model')[0] + const modelStartTime = Number(modelCsv.startTime) + const modelEndTime = Number(modelCsv.endTime) + + // Read the static strings from `strings.csv` + // const strings = readStringsCsv() + + return new ConfigContext(buildContext, modelStartTime, modelEndTime /*, strings*/) +} + +function configFilePath(configDir: string, name: string, ext: string): string { + return joinPath(configDir, `${name}.${ext}`) +} + +function readCsvFile(path: string): CsvRow[] { + const data = readFileSync(path, 'utf8') + return parseCsv(data, { + columns: true, + trim: true, + skip_empty_lines: true, + skip_lines_with_empty_values: true + }) +} + +function readConfigCsvFile(configDir: string, name: string): CsvRow[] { + return readCsvFile(configFilePath(configDir, name, 'csv')) +} + +/** + * Initialize a `Strings` instance with the core strings from `strings.csv`. + */ +// function readStringsCsv(configDir: string): Strings { +// const strings = new Strings() + +// const rows = readConfigCsvFile(configDir, 'strings') +// for (const row of rows) { +// const key = row['id'] +// let str = row['string'] + +// str = str ? str.trim() : '' +// if (str) { +// strings.add(key, str, layout, strCtxt, 'primary') +// } +// } + +// return strings +// } diff --git a/packages/plugin-config-csv/src/gen-model-spec.ts b/packages/plugin-config-csv/src/gen-model-spec.ts new file mode 100644 index 00000000..2c75f26c --- /dev/null +++ b/packages/plugin-config-csv/src/gen-model-spec.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2022 Climate Interactive / New Venture Fund. All rights reserved. + +import type { ConfigContext } from './context' +import { sdeNameForVensimVarName } from './var-names' + +/** + * Write the `model-spec.ts` file used by the core package to initialize the model. + */ +export function writeModelSpec(context: ConfigContext, dstDir: string): void { + // Create ordered arrays of inputs and outputs + const inputVarIds = context.getOrderedInputs().map(i => sdeNameForVensimVarName(i.varName)) + const outputVarIds = context.getOrderedOutputs().map(o => sdeNameForVensimVarName(o.varName)) + + // Generate the `model-spec.ts` file + let tsContent = '' + function emit(s: string): void { + tsContent += s + '\n' + } + + emit('// This file is generated by `@sdeverywhere/plugin-config-csv`; do not edit manually!') + emit(`export const startTime = ${context.modelStartTime}`) + emit(`export const endTime = ${context.modelEndTime}`) + emit(`export const inputVarIds: string[] = ${JSON.stringify(inputVarIds, null, 2)}`) + emit(`export const outputVarIds: string[] = ${JSON.stringify(outputVarIds, null, 2)}`) + + // Write the `model-spec.ts` file to the staged directory + context.writeStagedFile('model', dstDir, 'model-spec.ts', tsContent) +} diff --git a/packages/plugin-config-csv/src/index.ts b/packages/plugin-config-csv/src/index.ts new file mode 100644 index 00000000..6a4411c7 --- /dev/null +++ b/packages/plugin-config-csv/src/index.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2022 Climate Interactive / New Venture Fund + +export { configProcessor } from './processor' diff --git a/packages/plugin-config-csv/src/processor.spec.ts b/packages/plugin-config-csv/src/processor.spec.ts new file mode 100644 index 00000000..bc1d54f6 --- /dev/null +++ b/packages/plugin-config-csv/src/processor.spec.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2022 Climate Interactive / New Venture Fund + +import { mkdir, readFile } from 'fs/promises' +import { dirname, join as joinPath } from 'path' +import { fileURLToPath } from 'url' + +import dedent from 'dedent' +import temp from 'temp' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import type { BuildOptions, UserConfig } from '@sdeverywhere/build' +import { build } from '@sdeverywhere/build' + +import type { ConfigOptions } from './processor' +import { configProcessor } from './processor' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +interface TestEnv { + projDir: string + corePkgDir: string + buildOptions: BuildOptions +} + +async function prepareForBuild(optionsFunc: (corePkgDir: string) => ConfigOptions): Promise { + const baseTmpDir = await temp.mkdir('sde-plugin-config-csv') + console.log(baseTmpDir) + const projDir = joinPath(baseTmpDir, 'proj') + await mkdir(projDir) + const corePkgDir = joinPath(projDir, 'core-package') + await mkdir(corePkgDir) + + const config: UserConfig = { + rootDir: projDir, + modelFiles: [], + modelSpec: configProcessor(optionsFunc(corePkgDir)) + } + + const buildOptions: BuildOptions = { + config, + logLevels: ['info'], + sdeDir: '', + sdeCmdPath: '' + } + + return { + projDir, + corePkgDir, + buildOptions + } +} + +describe('configProcessor', () => { + beforeAll(() => { + temp.track() + }) + + afterAll(() => { + temp.cleanupSync() + }) + + it('should throw an error if the config directory does not exist', async () => { + const configDir = '/___does-not-exist___' + const testEnv = await prepareForBuild(() => ({ + config: configDir + })) + const result = await build('production', testEnv.buildOptions) + if (result.isOk()) { + throw new Error('Expected err result but got ok: ' + result.value) + } + expect(result.error.message).toBe(`The provided config dir '/___does-not-exist___' does not exist`) + }) + + it('should write to default directory structure if single out dir is provided', async () => { + const configDir = joinPath(__dirname, '__tests__', 'config1') + const testEnv = await prepareForBuild(corePkgDir => ({ + config: configDir, + out: corePkgDir + })) + const result = await build('production', testEnv.buildOptions) + if (result.isErr()) { + throw new Error('Expected ok result but got: ' + result.error.message) + } + + const modelSpecFile = joinPath(testEnv.corePkgDir, 'src', 'model', 'generated', 'model-spec.ts') + expect(await readFile(modelSpecFile, 'utf8')).toEqual(dedent` + // This file is generated by \`@sdeverywhere/plugin-config-csv\`; do not edit manually! + export const startTime = 100 + export const endTime = 200 + export const inputVarIds: string[] = [] + export const outputVarIds: string[] = []\n + `) + + // TODO: Check config specs and strings + }) + + it('should write to given directories if out paths are provided', async () => { + const configDir = joinPath(__dirname, '__tests__', 'config1') + const testEnv = await prepareForBuild(corePkgDir => ({ + config: configDir, + out: { + modelSpecsDir: joinPath(corePkgDir, 'mgen'), + configSpecsDir: joinPath(corePkgDir, 'cgen'), + stringsDir: joinPath(corePkgDir, 'sgen') + } + })) + const result = await build('production', testEnv.buildOptions) + if (result.isErr()) { + throw new Error('Expected ok result but got: ' + result.error.message) + } + + const modelSpecFile = joinPath(testEnv.corePkgDir, 'mgen', 'model-spec.ts') + expect(await readFile(modelSpecFile, 'utf8')).toEqual(dedent` + // This file is generated by \`@sdeverywhere/plugin-config-csv\`; do not edit manually! + export const startTime = 100 + export const endTime = 200 + export const inputVarIds: string[] = [] + export const outputVarIds: string[] = []\n + `) + + // TODO: Check config specs and strings + }) +}) diff --git a/packages/plugin-config-csv/src/processor.ts b/packages/plugin-config-csv/src/processor.ts new file mode 100644 index 00000000..a1ac0a05 --- /dev/null +++ b/packages/plugin-config-csv/src/processor.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2022 Climate Interactive / New Venture Fund + +import { existsSync } from 'fs' +import { join as joinPath } from 'path' + +import type { BuildContext, InputSpec, ModelSpec, OutputSpec } from '@sdeverywhere/build' + +import { createConfigContext } from './context' +import { writeModelSpec } from './gen-model-spec' + +export interface ConfigOutputPaths { + /** The absolute path to the directory where model spec files will be written. */ + modelSpecsDir?: string + + /** The absolute path to the directory where config spec files will be written. */ + configSpecsDir?: string + + /** The absolute path to the directory where translated strings will be written. */ + stringsDir?: string +} + +export interface ConfigOptions { + /** + * The absolute path to the directory containing the CSV config files. + */ + config: string + + /** + * Either a single path to a base output directory (in which case, the recommended + * directory structure will be used) or a `ConfigOutputPaths` containing specific paths. + * If a single string is provided, the following subdirectories will be used: + * / + * src/ + * config/ + * generated/ + * model/ + * generated/ + * strings/ + */ + out?: string | ConfigOutputPaths +} + +/** + * Returns a function that can be passed as the `modelSpec` function for the SDEverywhere + * `UserConfig`. The returned function: + * - reads CSV files from a `config` directory + * - writes JS files to the configured output directories + * - returns a `ModelSpec` that guides the rest of the `sde` build process + */ +export function configProcessor(options: ConfigOptions): (buildContext: BuildContext) => Promise { + return buildContext => { + return processModelConfig(buildContext, options) + } +} + +async function processModelConfig(buildContext: BuildContext, options: ConfigOptions): Promise { + const t0 = performance.now() + + // Resolve source (config) directory + if (!existsSync(options.config)) { + throw new Error(`The provided config dir '${options.config}' does not exist`) + } + + // Resolve output directories + let outModelSpecsDir: string + if (options.out) { + if (typeof options.out === 'string') { + outModelSpecsDir = joinPath(options.out, 'src', 'model', 'generated') + } else { + outModelSpecsDir = options.out.modelSpecsDir + } + } + + let outConfigSpecsDir: string + if (options.out) { + if (typeof options.out === 'string') { + outConfigSpecsDir = joinPath(options.out, 'src', 'config', 'generated') + } else { + outConfigSpecsDir = options.out.configSpecsDir + } + } + + let outStringsDir: string + if (options.out) { + if (typeof options.out === 'string') { + outStringsDir = joinPath(options.out, 'strings') + } else { + outStringsDir = options.out.stringsDir + } + } + + // Create a container for strings, variables, etc + const context = createConfigContext(buildContext, options.config) + + // Write the generated files + context.log('info', 'Generating files...') + + if (outModelSpecsDir) { + context.log('verbose', ' Writing model specs') + writeModelSpec(context, outModelSpecsDir) + } + + if (outConfigSpecsDir) { + // const configSpecs = generateConfigSpecs(context) + // context.log('verbose', ' Writing config specs') + // writeConfigSpecs(context, configSpecs) + } + + if (outStringsDir) { + // context.log('verbose', ' Writing translated strings') + // context.writeTranslationJsFiles(options.outStringsDir) + } + + const t1 = performance.now() + const elapsed = ((t1 - t0) / 1000).toFixed(1) + context.log('info', `Done generating files (${elapsed}s)`) + + // TODO: model.csv + const datFiles: string[] = [] + + // export interface InputSpec { + // /** The variable name (as used in the modeling tool). */ + // varName: string + + // /** The default value for the input. */ + // defaultValue: number + + // /** The minimum value for the input. */ + // minValue: number + + // /** The maximum value for the input. */ + // maxValue: number + // } + + // TODO + const inputs: InputSpec[] = [] + + // /** + // * Describes a model output variable. + // */ + // export interface OutputSpec { + // /** The variable name (as used in the modeling tool). */ + // varName: string + // } + + // TODO + const outputs: OutputSpec[] = [] + + return { + startTime: context.modelStartTime, + endTime: context.modelEndTime, + inputs, + outputs, + datFiles + } +} diff --git a/packages/plugin-config-csv/src/read-config.ts b/packages/plugin-config-csv/src/read-config.ts new file mode 100644 index 00000000..5cd695c4 --- /dev/null +++ b/packages/plugin-config-csv/src/read-config.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2022 Climate Interactive / New Venture Fund. All rights reserved. + +export function optionalString(stringValue?: string): string | undefined { + if (stringValue !== undefined && stringValue.length > 0) { + return stringValue + } else { + return undefined + } +} + +export function optionalNumber(stringValue?: string): number | undefined { + if (stringValue !== undefined && stringValue.length > 0) { + return Number(stringValue) + } else { + return undefined + } +} diff --git a/packages/plugin-config-csv/src/var-names.ts b/packages/plugin-config-csv/src/var-names.ts new file mode 100644 index 00000000..7e8ec653 --- /dev/null +++ b/packages/plugin-config-csv/src/var-names.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2022 Climate Interactive / New Venture Fund. All rights reserved. + +export type InputVarId = string +export type OutputVarId = string + +/** + * Helper function that converts a Vensim variable or subscript name + * into a valid C identifier as used by SDE. + * TODO: Import helper function from `sdeverywhere` package instead + */ +function sdeNameForVensimName(name: string): string { + return ( + '_' + + name + .trim() + .replace(/"/g, '_') + .replace(/\s+!$/g, '!') + .replace(/\s/g, '_') + .replace(/,/g, '_') + .replace(/-/g, '_') + .replace(/\./g, '_') + .replace(/\$/g, '_') + .replace(/'/g, '_') + .replace(/&/g, '_') + .replace(/%/g, '_') + .replace(/\//g, '_') + .replace(/\|/g, '_') + .toLowerCase() + ) +} + +/** + * Helper function that converts a Vensim variable name (possibly containing + * subscripts) into a valid C identifier as used by SDE. + * TODO: Import helper function from `sdeverywhere` package instead + */ +export function sdeNameForVensimVarName(varName: string): string { + const m = varName.match(/([^[]+)(?:\[([^\]]+)\])?/) + if (!m) { + throw new Error(`Invalid Vensim name: ${varName}`) + } + let id = sdeNameForVensimName(m[1]) + if (m[2]) { + const subscripts = m[2].split(',').map(x => sdeNameForVensimName(x)) + id += `[${subscripts.join('][')}]` + } + + return id +} diff --git a/packages/plugin-config-csv/tsconfig-base.json b/packages/plugin-config-csv/tsconfig-base.json new file mode 100644 index 00000000..01a7d112 --- /dev/null +++ b/packages/plugin-config-csv/tsconfig-base.json @@ -0,0 +1,17 @@ +// This contains the TypeScript configuration that is shared between +// testing (`tsconfig-test.json`) and production builds (`tsconfig-build.json`). +{ + "extends": "../../tsconfig-common.json", + "compilerOptions": { + "outDir": "./dist", + // Use "es2021" because this is the ES version for Node 16 + "target": "es2021", + // Use "es2020" because this is a Node module (using ESM) and we need to + // use dynamic imports + "module": "es2020", + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "types": ["node"] + } +} diff --git a/packages/plugin-config-csv/tsconfig-build.json b/packages/plugin-config-csv/tsconfig-build.json new file mode 100644 index 00000000..c16db5ed --- /dev/null +++ b/packages/plugin-config-csv/tsconfig-build.json @@ -0,0 +1,6 @@ +// This contains the TypeScript configuration for production builds. +{ + "extends": "./tsconfig-base.json", + "include": ["src/**/*"], + "exclude": ["src/**/_mocks/**/*", "**/*.spec.ts"] +} diff --git a/packages/plugin-config-csv/tsconfig-test.json b/packages/plugin-config-csv/tsconfig-test.json new file mode 100644 index 00000000..7bd4cad8 --- /dev/null +++ b/packages/plugin-config-csv/tsconfig-test.json @@ -0,0 +1,5 @@ +// This contains the TypeScript configuration for testing. +{ + "extends": "./tsconfig-base.json", + "include": ["src/**/*"] +} diff --git a/packages/plugin-config-csv/tsconfig.json b/packages/plugin-config-csv/tsconfig.json new file mode 100644 index 00000000..fc8b16a2 --- /dev/null +++ b/packages/plugin-config-csv/tsconfig.json @@ -0,0 +1,6 @@ +// This contains the TypeScript configuration for local development and testing. +// It is used as the TypeScript config for tools like VSCode that look for +// `tsconfig.json` by default. +{ + "extends": "./tsconfig-test.json" +} diff --git a/packages/plugin-config-csv/tsup.config.ts b/packages/plugin-config-csv/tsup.config.ts new file mode 100644 index 00000000..7b62ef95 --- /dev/null +++ b/packages/plugin-config-csv/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + tsconfig: 'tsconfig-build.json', + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + splitting: false, + sourcemap: true, + clean: true +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de46bd67..30596980 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,6 +52,14 @@ importers: '@sdeverywhere/plugin-worker': link:../../packages/plugin-worker sirv-cli: 2.0.2 + examples/hello-world/sde-prep/template-report: + specifiers: + '@sdeverywhere/check-core': ^0.1.0 + '@sdeverywhere/check-ui-shell': ^0.1.0 + dependencies: + '@sdeverywhere/check-core': link:../../../../packages/check-core + '@sdeverywhere/check-ui-shell': link:../../../../packages/check-ui-shell + examples/sample-check-app: specifiers: '@sdeverywhere/check-core': workspace:* @@ -91,6 +99,43 @@ importers: '@sdeverywhere/check-core': link:../../packages/check-core assert-never: 1.2.1 + examples/sir: + specifiers: + '@sdeverywhere/cli': ^0.7.0 + '@sdeverywhere/plugin-check': ^0.1.0 + '@sdeverywhere/plugin-config-csv': ^0.1.0 + '@sdeverywhere/plugin-vite': ^0.1.1 + '@sdeverywhere/plugin-wasm': ^0.1.0 + '@sdeverywhere/plugin-worker': ^0.1.0 + dependencies: + '@sdeverywhere/cli': link:../../packages/cli + '@sdeverywhere/plugin-check': link:../../packages/plugin-check + '@sdeverywhere/plugin-config-csv': link:../../packages/plugin-config-csv + '@sdeverywhere/plugin-vite': link:../../packages/plugin-vite + '@sdeverywhere/plugin-wasm': link:../../packages/plugin-wasm + '@sdeverywhere/plugin-worker': link:../../packages/plugin-worker + + examples/sir/packages/sir-app: + specifiers: + bootstrap-slider: 10.6.2 + chart.js: ^2.9.4 + jquery: ^3.5.1 + vite: ^2.9.12 + dependencies: + bootstrap-slider: 10.6.2 + chart.js: 2.9.4 + jquery: 3.6.1 + devDependencies: + vite: 2.9.12 + + examples/sir/packages/sir-core: + specifiers: + '@sdeverywhere/runtime': ^0.1.0 + '@sdeverywhere/runtime-async': ^0.1.0 + dependencies: + '@sdeverywhere/runtime': link:../../../../packages/runtime + '@sdeverywhere/runtime-async': link:../../../../packages/runtime-async + packages/build: specifiers: '@types/cross-spawn': ^6.0.2 @@ -236,6 +281,31 @@ importers: devDependencies: '@types/node': 16.11.40 + packages/plugin-config-csv: + specifiers: + '@sdeverywhere/build': ^0.1.1 + '@types/byline': ^4.2.33 + '@types/dedent': ^0.7.0 + '@types/marked': ^4.0.1 + '@types/node': ^16.11.7 + '@types/temp': ^0.9.1 + byline: ^5.0.0 + csv-parse: ^4.15.4 + dedent: ^0.7.0 + temp: ^0.9.4 + dependencies: + '@sdeverywhere/build': link:../build + byline: 5.0.0 + csv-parse: 4.16.3 + devDependencies: + '@types/byline': 4.2.33 + '@types/dedent': 0.7.0 + '@types/marked': 4.0.6 + '@types/node': 16.11.40 + '@types/temp': 0.9.1 + dedent: 0.7.0 + temp: 0.9.4 + packages/plugin-vite: specifiers: '@sdeverywhere/build': ^0.1.1 @@ -492,6 +562,12 @@ packages: - supports-color dev: true + /@types/byline/4.2.33: + resolution: {integrity: sha512-LJYez7wrWcJQQDknqZtrZuExMGP0IXmPl1rOOGDqLbu+H7UNNRfKNuSxCBcQMLH1EfjeWidLedC/hCc5dDfBog==} + dependencies: + '@types/node': 17.0.42 + dev: true + /@types/chai-subset/1.3.3: resolution: {integrity: sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==} dependencies: @@ -514,6 +590,10 @@ packages: '@types/node': 17.0.42 dev: true + /@types/dedent/0.7.0: + resolution: {integrity: sha512-EGlKlgMhnLt/cM4DbUSafFdrkeJoC9Mvnj0PUCU7tFmTjMjNRT957kXCx0wYm3JuEq4o4ZsS5vG+NlkM2DMd2A==} + dev: true + /@types/estree/0.0.39: resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} dev: false @@ -530,6 +610,10 @@ packages: resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==} dev: true + /@types/marked/4.0.6: + resolution: {integrity: sha512-ITAVUzsnVbhy5afxhs4PPPbrv2hKVEDH5BhhaQNQlVG0UNu+9A18XSdYr53nBdHZ0ADEQLl+ciOjXbs7eHdiQQ==} + dev: true + /@types/node/16.11.40: resolution: {integrity: sha512-7bOWglXUO6f21NG3YDI7hIpeMX3M59GG+DzZuzX2EkFKYUnRoxq3EOg4R0KNv2hxryY9M3UUqG5akwwsifrukw==} dev: true @@ -553,6 +637,12 @@ packages: '@types/node': 17.0.42 dev: true + /@types/temp/0.9.1: + resolution: {integrity: sha512-yDQ8Y+oQi9V7VkexwE6NBSVyNuyNFeGI275yWXASc2DjmxNicMi9O50KxDpNlST1kBbV9jKYBHGXhgNYFMPqtA==} + dependencies: + '@types/node': 17.0.42 + dev: true + /@typescript-eslint/eslint-plugin/5.27.1_aq7uryhocdbvbqum33pitcm3y4: resolution: {integrity: sha512-6dM5NKT57ZduNnJfpY81Phe9nc9wolnMCnknb1im6brWi1RYv84nbMS3olJa27B6+irUVV1X/Wb+Am0FjJdGFw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -818,6 +908,10 @@ packages: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} engines: {node: '>=8'} + /bootstrap-slider/10.6.2: + resolution: {integrity: sha512-8JTPZB9QVOdrGzYF3YgC3YW6ssfPeBvBwZnXffiZ7YH/zz1D0EKlZvmQsm/w3N0XjVNYQEoQ0ax+jHrErV4K1Q==} + dev: false + /brace-expansion/1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} dependencies: @@ -1061,6 +1155,10 @@ packages: dependencies: ms: 2.1.2 + /dedent/0.7.0: + resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==} + dev: true + /deep-eql/3.0.1: resolution: {integrity: sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==} engines: {node: '>=0.12'} @@ -2035,6 +2133,10 @@ packages: engines: {node: '>=10'} dev: true + /jquery/3.6.1: + resolution: {integrity: sha512-opJeO4nCucVnsjiXOE+/PcCgYw9Gwpvs/a6B1LL/lQhwWwpbVEVYDZ1FokFr8PRc7ghYlrFPuyHuiiDNTQxmcw==} + dev: false + /js-stringify/1.0.2: resolution: {integrity: sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==} dev: true @@ -2677,6 +2779,13 @@ packages: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + /rimraf/2.6.3: + resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} + hasBin: true + dependencies: + glob: 7.2.3 + dev: true + /rimraf/2.7.1: resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} hasBin: true @@ -3172,6 +3281,14 @@ packages: engines: {node: '>= 8'} dev: true + /temp/0.9.4: + resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} + engines: {node: '>=6.0.0'} + dependencies: + mkdirp: 0.5.6 + rimraf: 2.6.3 + dev: true + /text-table/0.2.0: resolution: {integrity: sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=} dev: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6a5a2d97..2c99b837 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,4 @@ packages: - packages/* - - examples/* + - examples/** - tests From 0032f439fe4641d2786e70dfdf6b9ff9cb2b6fdf Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Thu, 8 Sep 2022 16:28:47 -0700 Subject: [PATCH 03/24] feat: read graphs.csv --- examples/sir/config/colors.csv | 6 + examples/sir/config/outputs.csv | 1 + examples/sir/packages/sir-core/src/index.js | 2 +- packages/plugin-config-csv/package.json | 4 +- .../src/__tests__/config1/colors.csv | 3 + .../src/__tests__/config1/graphs.csv | 3 +- .../src/__tests__/config1/model.csv | 4 +- .../src/__tests__/config1/outputs.csv | 1 + packages/plugin-config-csv/src/context.ts | 126 ++++++-- .../plugin-config-csv/src/gen-config-specs.ts | 72 +++++ packages/plugin-config-csv/src/gen-graphs.ts | 278 ++++++++++++++++++ .../plugin-config-csv/src/gen-model-spec.ts | 2 +- .../plugin-config-csv/src/processor.spec.ts | 87 ++++-- packages/plugin-config-csv/src/processor.ts | 7 +- packages/plugin-config-csv/src/read-config.ts | 2 +- .../src/spec-types/graphs.ts | 103 +++++++ .../plugin-config-csv/src/spec-types/types.ts | 16 + packages/plugin-config-csv/src/strings.ts | 196 ++++++++++++ packages/plugin-config-csv/src/var-names.ts | 2 +- pnpm-lock.yaml | 65 +++- 20 files changed, 927 insertions(+), 53 deletions(-) create mode 100644 examples/sir/config/colors.csv create mode 100644 examples/sir/config/outputs.csv create mode 100644 packages/plugin-config-csv/src/__tests__/config1/colors.csv create mode 100644 packages/plugin-config-csv/src/__tests__/config1/outputs.csv create mode 100644 packages/plugin-config-csv/src/gen-config-specs.ts create mode 100644 packages/plugin-config-csv/src/gen-graphs.ts create mode 100644 packages/plugin-config-csv/src/spec-types/graphs.ts create mode 100644 packages/plugin-config-csv/src/spec-types/types.ts create mode 100644 packages/plugin-config-csv/src/strings.ts diff --git a/examples/sir/config/colors.csv b/examples/sir/config/colors.csv new file mode 100644 index 00000000..d8665198 --- /dev/null +++ b/examples/sir/config/colors.csv @@ -0,0 +1,6 @@ +id,hex code,name,comment +blue,#0072b2,, +red,#d33700,, +green,#53bb37,, +gray,#a7a9ac,, +black,#000000,, diff --git a/examples/sir/config/outputs.csv b/examples/sir/config/outputs.csv new file mode 100644 index 00000000..72a4edf6 --- /dev/null +++ b/examples/sir/config/outputs.csv @@ -0,0 +1 @@ +variable name diff --git a/examples/sir/packages/sir-core/src/index.js b/examples/sir/packages/sir-core/src/index.js index cd56b93a..cde06e23 100644 --- a/examples/sir/packages/sir-core/src/index.js +++ b/examples/sir/packages/sir-core/src/index.js @@ -1,6 +1,6 @@ import { ModelScheduler } from '@sdeverywhere/runtime' import { spawnAsyncModelRunner } from '@sdeverywhere/runtime-async' -import modelWorkerJs from './generated/worker.js?raw' +import modelWorkerJs from './model/generated/worker.js?raw' export class Model { constructor() {} diff --git a/packages/plugin-config-csv/package.json b/packages/plugin-config-csv/package.json index 2e12bc91..d83bfa69 100644 --- a/packages/plugin-config-csv/package.json +++ b/packages/plugin-config-csv/package.json @@ -31,13 +31,15 @@ "dependencies": { "@sdeverywhere/build": "^0.1.1", "byline": "^5.0.0", - "csv-parse": "^4.15.4" + "csv-parse": "^4.15.4", + "sanitize-html": "^2.7.1" }, "devDependencies": { "@types/byline": "^4.2.33", "@types/dedent": "^0.7.0", "@types/marked": "^4.0.1", "@types/node": "^16.11.7", + "@types/sanitize-html": "^2.6.2", "@types/temp": "^0.9.1", "dedent": "^0.7.0", "temp": "^0.9.4" diff --git a/packages/plugin-config-csv/src/__tests__/config1/colors.csv b/packages/plugin-config-csv/src/__tests__/config1/colors.csv new file mode 100644 index 00000000..78aafaa4 --- /dev/null +++ b/packages/plugin-config-csv/src/__tests__/config1/colors.csv @@ -0,0 +1,3 @@ +id,hex code,name,comment +baseline,#000000,black,baseline +current_scenario,#0000ff,blue,current scenario diff --git a/packages/plugin-config-csv/src/__tests__/config1/graphs.csv b/packages/plugin-config-csv/src/__tests__/config1/graphs.csv index 1d20bde6..10a5e520 100644 --- a/packages/plugin-config-csv/src/__tests__/config1/graphs.csv +++ b/packages/plugin-config-csv/src/__tests__/config1/graphs.csv @@ -1 +1,2 @@ -id,side,parent menu,graph title,menu title,mini title,vensim graph,kind,modes,units,alternate,unused 1,unused 2,unused 3,x axis min,x axis max,x axis label,unused 4,unused 5,y axis min,y axis max,y axis soft max,y axis label,y axis format,unused 6,unused 7,plot 1 variable,plot 1 source,plot 1 style,plot 1 label,plot 1 color,plot 1 unused 1,plot 1 unused 2,plot 2 variable,plot 2 source,plot 2 style,plot 2 label,plot 2 color,plot 2 unused 1,plot 2 unused 2,plot 3 variable,plot 3 source,plot 3 style,plot 3 label,plot 3 color,plot 3 unused 1,plot 3 unused 2,plot 4 variable,plot 4 source,plot 4 style,plot 4 label,plot 4 color,plot 4 unused 1,plot 4 unused 2,plot 5 variable,plot 5 source,plot 5 style,plot 5 label,plot 5 color,plot 5 unused 1,plot 5 unused 2,plot 6 variable,plot 6 source,plot 6 style,plot 6 label,plot 6 color,plot 6 unused 1,plot 6 unused 2,plot 7 variable,plot 7 source,plot 7 style,plot 7 label,plot 7 color,plot 7 unused 1,plot 7 unused 2,plot 8 variable,plot 8 source,plot 8 style,plot 8 label,plot 8 color,plot 8 unused 1,plot 8 unused 2,plot 9 variable,plot 9 source,plot 9 style,plot 9 label,plot 9 color,plot 9 unused 1,plot 9 unused 2,plot 10 variable,plot 10 source,plot 10 style,plot 10 label,plot 10 color,plot 10 unused 1,plot 10 unused 2,plot 11 variable,plot 11 source,plot 11 style,plot 11 label,plot 11 color,plot 11 unused 1,plot 11 unused 2,plot 12 variable,plot 12 source,plot 12 style,plot 12 label,plot 12 color,plot 12 unused 1,plot 12 unused 2,plot 13 variable,plot 13 source,plot 13 style,plot 13 label,plot 13 color,plot 13 unused 1,plot 13 unused 2,plot 14 variable,plot 14 source,plot 14 style,plot 14 label,plot 14 color,plot 14 unused 1,plot 14 unused 2,plot 15 variable,plot 15 source,plot 15 style,plot 15 label,plot 15 color,plot 15 unused 1,plot 15 unused 2 +id,side,parent menu,graph title,menu title,mini title,vensim graph,kind,modes,units,alternate,unused 1,unused 2,unused 3,x axis min,x axis max,x axis label,unused 4,unused 5,y axis min,y axis max,y axis soft max,y axis label,y axis format,unused 6,unused 7,plot 1 variable,plot 1 source,plot 1 style,plot 1 label,plot 1 color,plot 1 unused 1,plot 1 unused 2,plot 2 variable,plot 2 source,plot 2 style,plot 2 label,plot 2 color,plot 2 unused 1,plot 2 unused 2,plot 3 variable,plot 3 source,plot 3 style,plot 3 label,plot 3 color,plot 3 unused 1,plot 3 unused 2,plot 4 variable,plot 4 source,plot 4 style,plot 4 label,plot 4 color,plot 4 unused 1,plot 4 unused 2,plot 5 variable,plot 5 source,plot 5 style,plot 5 label,plot 5 color,plot 5 unused 1,plot 5 unused 2,plot 6 variable,plot 6 source,plot 6 style,plot 6 label,plot 6 color,plot 6 unused 1,plot 6 unused 2,plot 7 variable,plot 7 source,plot 7 style,plot 7 label,plot 7 color,plot 7 unused 1,plot 7 unused 2,plot 8 variable,plot 8 source,plot 8 style,plot 8 label,plot 8 color,plot 8 unused 1,plot 8 unused 2,plot 9 variable,plot 9 source,plot 9 style,plot 9 label,plot 9 color,plot 9 unused 1,plot 9 unused 2,plot 10 variable,plot 10 source,plot 10 style,plot 10 label,plot 10 color,plot 10 unused 1,plot 10 unused 2,plot 11 variable,plot 11 source,plot 11 style,plot 11 label,plot 11 color,plot 11 unused 1,plot 11 unused 2,plot 12 variable,plot 12 source,plot 12 style,plot 12 label,plot 12 color,plot 12 unused 1,plot 12 unused 2,plot 13 variable,plot 13 source,plot 13 style,plot 13 label,plot 13 color,plot 13 unused 1,plot 13 unused 2,plot 14 variable,plot 14 source,plot 14 style,plot 14 label,plot 14 color,plot 14 unused 1,plot 14 unused 2,plot 15 variable,plot 15 source,plot 15 style,plot 15 label,plot 15 color,plot 15 unused 1,plot 15 unused 2,description +1,,Parent Menu 1,Graph 1 Title,,,,line,,,,,,,50,100,X-Axis,,,,300,,Y-Axis,,,,Var 1,Ref,line,Baseline,baseline,,,Var 1,,line,Current Scenario,current_scenario,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, \ No newline at end of file diff --git a/packages/plugin-config-csv/src/__tests__/config1/model.csv b/packages/plugin-config-csv/src/__tests__/config1/model.csv index 6e386d87..295e28ad 100644 --- a/packages/plugin-config-csv/src/__tests__/config1/model.csv +++ b/packages/plugin-config-csv/src/__tests__/config1/model.csv @@ -1,2 +1,2 @@ -startTime,endTime -100,200 +model start time,model end time,graph min time,graph max time +0,200,0,200 diff --git a/packages/plugin-config-csv/src/__tests__/config1/outputs.csv b/packages/plugin-config-csv/src/__tests__/config1/outputs.csv new file mode 100644 index 00000000..72a4edf6 --- /dev/null +++ b/packages/plugin-config-csv/src/__tests__/config1/outputs.csv @@ -0,0 +1 @@ +variable name diff --git a/packages/plugin-config-csv/src/context.ts b/packages/plugin-config-csv/src/context.ts index 908eb4be..04562078 100644 --- a/packages/plugin-config-csv/src/context.ts +++ b/packages/plugin-config-csv/src/context.ts @@ -7,20 +7,40 @@ import parseCsv from 'csv-parse/lib/sync.js' import type { BuildContext, InputSpec, LogLevel, OutputSpec } from '@sdeverywhere/build' +import { Strings } from './strings' import type { InputVarId, OutputVarId } from './var-names' +import { sdeNameForVensimVarName } from './var-names' + +import type { HexColor } from './spec-types/graphs' export type CsvRow = { [key: string]: string } +export type ColorId = string export class ConfigContext { private readonly inputSpecs: Map = new Map() private readonly outputVarNames: Map = new Map() + private readonly staticVarNames: Map> = new Map() constructor( private readonly buildContext: BuildContext, + private readonly configDir: string, + public readonly strings: Strings, + private readonly colorMap: Map, public readonly modelStartTime: number, - public readonly modelEndTime: number + public readonly modelEndTime: number, + public readonly graphDefaultMinTime: number, + public readonly graphDefaultMaxTime: number ) {} + /** + * Read a CSV file of the given name from the config directory. + * + * @param name The base name of the CSV file. + */ + readConfigCsvFile(name: string): CsvRow[] { + return readConfigCsvFile(this.configDir, name) + } + /** * Log a message to the console and/or the in-browser overlay panel. * @@ -51,6 +71,45 @@ export class ConfigContext { this.buildContext.writeStagedFile(srcDir, dstDir, filename, content) } + addInputVariable(inputVarName: string, defaultValue: number, minValue: number, maxValue: number): void { + // We use the C name as the key to avoid redundant entries in cases where + // the csv file refers to variables with different capitalization + const varId = sdeNameForVensimVarName(inputVarName) + if (this.inputSpecs.get(varId)) { + // Fail if the variable was already added (there should only be one spec + // per input variable) + console.error(`ERROR: Input variable ${inputVarName} was already added`) + } + this.inputSpecs.set(varId, { + varName: inputVarName, + defaultValue, + minValue, + maxValue + }) + } + + addOutputVariable(outputVarName: string): void { + // We use the C name as the key to avoid redundant entries in cases where + // the csv file refers to variables with different capitalization + const varId = sdeNameForVensimVarName(outputVarName) + this.outputVarNames.set(varId, outputVarName) + } + + addStaticVariable(sourceName: string, varName: string): void { + const sourceVarNames = this.staticVarNames.get(sourceName) + if (sourceVarNames) { + sourceVarNames.add(varName) + } else { + const varNames: Set = new Set() + varNames.add(varName) + this.staticVarNames.set(sourceName, varNames) + } + } + + getHexColorForId(colorId: ColorId): HexColor { + return this.colorMap.get(colorId) + } + getOrderedInputs(): InputSpec[] { // TODO: It would be nice to alphabetize the inputs, but currently we have // code that assumes that the InputSpecs in the map have the same order @@ -77,15 +136,35 @@ export class ConfigContext { } export function createConfigContext(buildContext: BuildContext, configDir: string): ConfigContext { - // Read basic app configuration from `model.csv` + // Read basic model configuration from `model.csv` const modelCsv = readConfigCsvFile(configDir, 'model')[0] - const modelStartTime = Number(modelCsv.startTime) - const modelEndTime = Number(modelCsv.endTime) + const modelStartTime = Number(modelCsv['model start time']) + const modelEndTime = Number(modelCsv['model end time']) + const graphDefaultMinTime = Number(modelCsv['graph min time']) + const graphDefaultMaxTime = Number(modelCsv['graph max time']) // Read the static strings from `strings.csv` - // const strings = readStringsCsv() + const strings = readStringsCsv(configDir) + + // Read color configuration from `colors.csv` + const colorsCsv = readConfigCsvFile(configDir, 'colors') + const colors = new Map() + for (const row of colorsCsv) { + const colorId = row['id'] + const hexColor = row['hex code'] + colors.set(colorId, hexColor) + } - return new ConfigContext(buildContext, modelStartTime, modelEndTime /*, strings*/) + return new ConfigContext( + buildContext, + configDir, + strings, + colors, + modelStartTime, + modelEndTime, + graphDefaultMinTime, + graphDefaultMaxTime + ) } function configFilePath(configDir: string, name: string, ext: string): string { @@ -109,19 +188,22 @@ function readConfigCsvFile(configDir: string, name: string): CsvRow[] { /** * Initialize a `Strings` instance with the core strings from `strings.csv`. */ -// function readStringsCsv(configDir: string): Strings { -// const strings = new Strings() - -// const rows = readConfigCsvFile(configDir, 'strings') -// for (const row of rows) { -// const key = row['id'] -// let str = row['string'] - -// str = str ? str.trim() : '' -// if (str) { -// strings.add(key, str, layout, strCtxt, 'primary') -// } -// } - -// return strings -// } +function readStringsCsv(configDir: string): Strings { + const strings = new Strings() + + // TODO: For now we use the same "layout" and "context" for all core strings + const layout = 'default' + const context = 'Core' + + const rows = readConfigCsvFile(configDir, 'strings') + for (const row of rows) { + const key = row['id'] + let str = row['string'] + str = str ? str.trim() : '' + if (str) { + strings.add(key, str, layout, context) + } + } + + return strings +} diff --git a/packages/plugin-config-csv/src/gen-config-specs.ts b/packages/plugin-config-csv/src/gen-config-specs.ts new file mode 100644 index 00000000..b3c6a671 --- /dev/null +++ b/packages/plugin-config-csv/src/gen-config-specs.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2022 Climate Interactive / New Venture Fund + +import type { ConfigContext } from './context' +import { generateGraphSpecs } from './gen-graphs' +import type { GraphId, GraphSpec } from './spec-types/graphs' + +export interface ConfigSpecs { + graphSpecs: Map + // inputSpecs: Map +} + +/** + * Convert the CSV files in the `config` directory to config specs that can be + * used in the core package. + */ +export function generateConfigSpecs(context: ConfigContext): ConfigSpecs { + // Convert `graphs.csv` to graph specs + context.log('verbose', ' Reading graph specs') + const graphSpecs = generateGraphSpecs(context) + + // // Convert `inputs.csv` to input specs + // context.log('verbose', ' Reading input specs') + // const inputsConfig = generateInputsConfig(context) + + // Include extra output variables that should be included in the generated + // model even though they are not referenced in any graph specs + context.log('verbose', ' Reading extra output variables') + const extraOutputsCsv = context.readConfigCsvFile('outputs') + for (const row of extraOutputsCsv) { + const varName = row['variable name'] + if (varName) { + context.addOutputVariable(varName) + } + } + + return { + graphSpecs + // inputSpecs + } +} + +/** + * Write the `config-specs.ts` file to the given destination directory. + */ +export function writeConfigSpecs(context: ConfigContext, config: ConfigSpecs, dstDir: string): void { + // Generate one big string containing the TypeScript source that will be + // loaded by `config.ts` at runtime + let tsContent = '' + function emit(s: string): void { + tsContent += s + '\n' + } + + emit('// This file is generated by `@sdeverywhere/plugin-config-csv`; do not edit manually!') + emit('') + emit(`import type { GraphSpec } from 'TODO'`) + // emit(`import type { InputSpec } from 'TODO'`) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function emitArray(type: string, values: Iterable): void { + const varName = type.charAt(0).toLowerCase() + type.slice(1) + 's' + const array = Array.from(values) + const json = JSON.stringify(array, null, 2) + emit('') + emit(`export const ${varName}: ${type}[] = ${json}`) + } + + emitArray('GraphSpec', config.graphSpecs.values()) + // emitArray('InputSpec', config.inputSpecs.values()) + + // Write the `config-specs.ts` file + context.writeStagedFile('config', dstDir, 'config-specs.ts', tsContent) +} diff --git a/packages/plugin-config-csv/src/gen-graphs.ts b/packages/plugin-config-csv/src/gen-graphs.ts new file mode 100644 index 00000000..b840e94f --- /dev/null +++ b/packages/plugin-config-csv/src/gen-graphs.ts @@ -0,0 +1,278 @@ +// Copyright (c) 2021-2022 Climate Interactive / New Venture Fund + +import type { ConfigContext, CsvRow } from './context' +import { optionalNumber, optionalString } from './read-config' + +import type { + GraphAlternateSpec, + GraphDatasetSpec, + GraphId, + GraphKind, + GraphLegendItemSpec, + GraphSide, + GraphSpec, + LineStyle, + LineStyleModifier +} from './spec-types/graphs' +import type { StringKey, UnitSystem } from './spec-types/types' +import { genStringKey, htmlToUtf8 } from './strings' +import { sdeNameForVensimVarName } from './var-names' + +/** + * Convert the `config/graphs.csv` file to config specs that can be used in + * the core package. + */ +export function generateGraphSpecs(context: ConfigContext): Map { + // TODO: Optionally read the graph descriptions from `graphs.md` + // let descriptions: Map + // if (useDescriptions) { + // descriptions = readGraphDescriptions(context) + // } else { + // descriptions = undefined + // } + + // Convert `graphs.csv` to graph specs + const graphsCsv = context.readConfigCsvFile('graphs') + const graphSpecs: Map = new Map() + for (const row of graphsCsv) { + const spec = graphSpecFromCsv(row, context) + if (spec) { + graphSpecs.set(spec.id, spec) + } + } + + return graphSpecs +} + +function graphSpecFromCsv(g: CsvRow, context: ConfigContext): GraphSpec | undefined { + const strings = context.strings + + // TODO: For now, all strings use the same "layout" specifier; this could be customized + // to provide a "maximum length" hint for a group of strings to the translation tool + const layout = 'default' + + function requiredString(key: string): string { + const value = g[key] + if (value === undefined || typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`Must specify '${key}' for graph ${g.id}`) + } + return value + } + + // Extract required fields + const graphIdParts = requiredString('id').split(';') + const graphId = graphIdParts[0] + const graphIdBaseParts = graphId.split('-') + const graphBaseId = graphIdBaseParts[0] + const title = requiredString('graph title') + + // Extract optional fields + const menuTitle = optionalString(g['menu title']) + const miniTitle = optionalString(g['mini title']) + const parentMenu = optionalString(g['parent menu']) + const description = optionalString(g['description']) + const kindString = optionalString(g['kind']) + + // Skip rows that have an empty `parent menu` value; this can be used to omit graphs + // from the product until they've been fully reviewed and approved + if (!parentMenu) { + context.log('info', `Skipping graph ${graphId} (${title})`) + return undefined + } + + // TODO: Check for a description + // let desc: Description + // if (descriptions) { + // desc = descriptions.get(graphBaseId) + // if (!desc) { + // throw new Error(`Graph description for ${graphBaseId} not found in graphs.md`) + // } + // } + + // Helper that creates a string key prefix + const key = (kind: string) => `graph_${graphBaseId.padStart(3, '0')}_${kind}` + + // Helper that creates a string context + const strCtxt = (kind: string) => { + const parent = htmlToUtf8(parentMenu).replace('&', '&') + const displayTitle = htmlToUtf8(menuTitle || title).replace('&', '&') + return `Graph ${kind}: ${parent} > ${displayTitle}` + } + + const titleKey = strings.add(key('title'), title, layout, strCtxt('Title')) + let menuTitleKey: StringKey + if (menuTitle) { + menuTitleKey = strings.add(key('menu_title'), menuTitle, layout, strCtxt('Menu Item')) + } + + let miniTitleKey: StringKey + if (miniTitle) { + miniTitleKey = strings.add(key('mini_title'), miniTitle, layout, strCtxt('Title (for Mini View)')) + } + + let descriptionKey: StringKey + if (description) { + descriptionKey = strings.add(key('description'), description, layout, strCtxt('Description'), 'graph-descriptions') + } + + // TODO: Validate kind? + const kind: GraphKind = kindString + + // TODO: Validate graph side? + const sideString = optionalString(g['side']) + const side: GraphSide = sideString + + // Determine if this graph is associated with a particular unit system and + // has an alternate version + const unitsString = optionalString(g['units']) + const altIdString = optionalString(g['alternate']) + let unitSystem: UnitSystem + let alternates: GraphAlternateSpec[] + if (unitsString && altIdString) { + if (unitsString === 'metric') { + // This graph is metric with a U.S. alternate + unitSystem = 'metric' + alternates = [ + { + id: altIdString, + unitSystem: 'us' + } + ] + } else if (unitsString === 'us') { + // This graph is U.S. with a metric alternate + unitSystem = 'us' + alternates = [ + { + id: altIdString, + unitSystem: 'metric' + } + ] + } + } + + const xMin = optionalNumber(g['x axis min']) || context.graphDefaultMinTime + const xMax = optionalNumber(g['x axis max']) || context.graphDefaultMaxTime + const xAxisLabel = optionalString(g['x axis label']) + let xAxisLabelKey: StringKey + if (xAxisLabel) { + xAxisLabelKey = strings.add(genStringKey('graph_xaxis_label', xAxisLabel), xAxisLabel, layout, 'Graph X-Axis Label') + } + + const yMin = optionalNumber(g['y axis min']) || 0 + const yMax = optionalNumber(g['y axis max']) + const ySoftMax = optionalNumber(g['y axis soft max']) + const yFormat = optionalString(g['y axis format']) || '.0f' + const yAxisLabel = optionalString(g['y axis label']) + let yAxisLabelKey: StringKey + if (yAxisLabel) { + yAxisLabelKey = strings.add(genStringKey('graph_yaxis_label', yAxisLabel), yAxisLabel, layout, 'Graph Y-Axis Label') + } + + const datasets: GraphDatasetSpec[] = [] + interface Overrides { + sourceName?: string + colorId?: string + } + function addDataset(index: number, overrides?: Overrides): void { + const plotKey = (name: string) => `plot ${index} ${name}` + const varName = g[plotKey('variable')] + if (!varName) { + return + } + + const varId = sdeNameForVensimVarName(varName) + const externalSourceName = overrides?.sourceName || optionalString(g[plotKey('source')]) + const datasetLabel = optionalString(g[plotKey('label')]) + let labelKey: StringKey + if (datasetLabel) { + labelKey = strings.add( + genStringKey('graph_dataset_label', datasetLabel), + datasetLabel, + layout, + 'Graph Dataset Label' + ) + } + + const colorId = overrides?.colorId || requiredString(plotKey('color')) + const hexColor = context.getHexColorForId(colorId) + if (!hexColor) { + throw new Error(`Graph ${graphId} references an unknown color ${colorId}`) + } + + const lineStyleAndModString = optionalString(g[plotKey('style')]) || 'line' + const lineStyleParts = lineStyleAndModString.split(';') + const lineStyleString = lineStyleParts[0] + const lineStyleModifierString = lineStyleParts.length > 1 ? lineStyleParts[1] : undefined + + // TODO: Validate line style and modifiers? + const lineStyle: LineStyle = lineStyleString + let lineStyleModifiers: ReadonlyArray + if (lineStyleModifierString) { + // TODO: For now, we assume at most one modifier; should change this to allow > 1 + lineStyleModifiers = [lineStyleModifierString] + } + + if (externalSourceName && externalSourceName !== 'Ref') { + // Add the variable to the set of vars to be included in the static data file + context.addStaticVariable(externalSourceName, varName) + } else { + // Add the variable if this is a normal model output (in which case the source + // name is undefined) or if it will be captured as "Ref" (baseline) values + context.addOutputVariable(varName) + } + + const datasetSpec: GraphDatasetSpec = { + varId, + varName, + externalSourceName, + labelKey, + color: hexColor, + lineStyle, + lineStyleModifiers + } + datasets.push(datasetSpec) + } + + // Add each dataset configured in graphs.csv + for (let i = 1; i <= 11; i++) { + addDataset(i) + } + + // Only show legend items for datasets that have a label (i.e., ignore + // some special ones, like the ones used to show dotted reference lines) + const legendItems: GraphLegendItemSpec[] = datasets + .filter(dataset => dataset.labelKey?.length > 0) + .map(dataset => { + return { + color: dataset.color, + labelKey: dataset.labelKey + } + }) + + const graphSpec: GraphSpec = { + id: graphId, + kind, + titleKey, + miniTitleKey, + menuTitleKey, + descriptionKey, + side, + unitSystem, + alternates, + xMin, + xMax, + xAxisLabelKey, + yMin, + yMax, + ySoftMax, + yAxisLabelKey, + yFormat, + datasets, + legendItems + } + + // Add the graph to the menu + // context.addGraphMenuItem(graphSpec, parentMenu) + + return graphSpec +} diff --git a/packages/plugin-config-csv/src/gen-model-spec.ts b/packages/plugin-config-csv/src/gen-model-spec.ts index 2c75f26c..608afd56 100644 --- a/packages/plugin-config-csv/src/gen-model-spec.ts +++ b/packages/plugin-config-csv/src/gen-model-spec.ts @@ -1,4 +1,4 @@ -// Copyright (c) 2022 Climate Interactive / New Venture Fund. All rights reserved. +// Copyright (c) 2022 Climate Interactive / New Venture Fund import type { ConfigContext } from './context' import { sdeNameForVensimVarName } from './var-names' diff --git a/packages/plugin-config-csv/src/processor.spec.ts b/packages/plugin-config-csv/src/processor.spec.ts index bc1d54f6..9bae9248 100644 --- a/packages/plugin-config-csv/src/processor.spec.ts +++ b/packages/plugin-config-csv/src/processor.spec.ts @@ -4,7 +4,6 @@ import { mkdir, readFile } from 'fs/promises' import { dirname, join as joinPath } from 'path' import { fileURLToPath } from 'url' -import dedent from 'dedent' import temp from 'temp' import { afterAll, beforeAll, describe, expect, it } from 'vitest' @@ -50,6 +49,62 @@ async function prepareForBuild(optionsFunc: (corePkgDir: string) => ConfigOption } } +const modelSpec1 = `\ +// This file is generated by \`@sdeverywhere/plugin-config-csv\`; do not edit manually! +export const startTime = 0 +export const endTime = 200 +export const inputVarIds: string[] = [] +export const outputVarIds: string[] = [] +` + +const configSpecs1 = `\ +// This file is generated by \`@sdeverywhere/plugin-config-csv\`; do not edit manually! + +import type { GraphSpec } from './spec-types' + +export const graphSpecs: GraphSpec[] = [ + { + "id": "1", + "kind": "line", + "titleKey": "graph_001_title", + "xMin": 50, + "xMax": 100, + "xAxisLabelKey": "graph_xaxis_label__x_axis", + "yMin": 0, + "yMax": 300, + "yAxisLabelKey": "graph_yaxis_label__y_axis", + "yFormat": ".0f", + "datasets": [ + { + "varId": "_var_1", + "varName": "Var 1", + "externalSourceName": "Ref", + "labelKey": "graph_dataset_label__baseline", + "color": "#000000", + "lineStyle": "line" + }, + { + "varId": "_var_1", + "varName": "Var 1", + "labelKey": "graph_dataset_label__current_scenario", + "color": "#0000ff", + "lineStyle": "line" + } + ], + "legendItems": [ + { + "color": "#000000", + "labelKey": "graph_dataset_label__baseline" + }, + { + "color": "#0000ff", + "labelKey": "graph_dataset_label__current_scenario" + } + ] + } +] +` + describe('configProcessor', () => { beforeAll(() => { temp.track() @@ -83,15 +138,12 @@ describe('configProcessor', () => { } const modelSpecFile = joinPath(testEnv.corePkgDir, 'src', 'model', 'generated', 'model-spec.ts') - expect(await readFile(modelSpecFile, 'utf8')).toEqual(dedent` - // This file is generated by \`@sdeverywhere/plugin-config-csv\`; do not edit manually! - export const startTime = 100 - export const endTime = 200 - export const inputVarIds: string[] = [] - export const outputVarIds: string[] = []\n - `) - - // TODO: Check config specs and strings + expect(await readFile(modelSpecFile, 'utf8')).toEqual(modelSpec1) + + const configSpecsFile = joinPath(testEnv.corePkgDir, 'src', 'config', 'generated', 'config-specs.ts') + expect(await readFile(configSpecsFile, 'utf8')).toEqual(configSpecs1) + + // TODO: Check strings }) it('should write to given directories if out paths are provided', async () => { @@ -110,14 +162,11 @@ describe('configProcessor', () => { } const modelSpecFile = joinPath(testEnv.corePkgDir, 'mgen', 'model-spec.ts') - expect(await readFile(modelSpecFile, 'utf8')).toEqual(dedent` - // This file is generated by \`@sdeverywhere/plugin-config-csv\`; do not edit manually! - export const startTime = 100 - export const endTime = 200 - export const inputVarIds: string[] = [] - export const outputVarIds: string[] = []\n - `) - - // TODO: Check config specs and strings + expect(await readFile(modelSpecFile, 'utf8')).toEqual(modelSpec1) + + const configSpecsFile = joinPath(testEnv.corePkgDir, 'cgen', 'config-specs.ts') + expect(await readFile(configSpecsFile, 'utf8')).toEqual(configSpecs1) + + // TODO: Check strings }) }) diff --git a/packages/plugin-config-csv/src/processor.ts b/packages/plugin-config-csv/src/processor.ts index a1ac0a05..fc4641de 100644 --- a/packages/plugin-config-csv/src/processor.ts +++ b/packages/plugin-config-csv/src/processor.ts @@ -7,6 +7,7 @@ import type { BuildContext, InputSpec, ModelSpec, OutputSpec } from '@sdeverywhe import { createConfigContext } from './context' import { writeModelSpec } from './gen-model-spec' +import { generateConfigSpecs, writeConfigSpecs } from './gen-config-specs' export interface ConfigOutputPaths { /** The absolute path to the directory where model spec files will be written. */ @@ -101,9 +102,9 @@ async function processModelConfig(buildContext: BuildContext, options: ConfigOpt } if (outConfigSpecsDir) { - // const configSpecs = generateConfigSpecs(context) - // context.log('verbose', ' Writing config specs') - // writeConfigSpecs(context, configSpecs) + const configSpecs = generateConfigSpecs(context) + context.log('verbose', ' Writing config specs') + writeConfigSpecs(context, configSpecs, outConfigSpecsDir) } if (outStringsDir) { diff --git a/packages/plugin-config-csv/src/read-config.ts b/packages/plugin-config-csv/src/read-config.ts index 5cd695c4..49b2c009 100644 --- a/packages/plugin-config-csv/src/read-config.ts +++ b/packages/plugin-config-csv/src/read-config.ts @@ -1,4 +1,4 @@ -// Copyright (c) 2022 Climate Interactive / New Venture Fund. All rights reserved. +// Copyright (c) 2022 Climate Interactive / New Venture Fund export function optionalString(stringValue?: string): string | undefined { if (stringValue !== undefined && stringValue.length > 0) { diff --git a/packages/plugin-config-csv/src/spec-types/graphs.ts b/packages/plugin-config-csv/src/spec-types/graphs.ts new file mode 100644 index 00000000..94c5db8d --- /dev/null +++ b/packages/plugin-config-csv/src/spec-types/graphs.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2020-2022 Climate Interactive / New Venture Fund + +import type { FormatString, OutputVarId, StringKey, UnitSystem } from './types' + +/** A hex color code (e.g. '#ff0033'). */ +export type HexColor = string + +/** A line style specifier (e.g., 'line', 'dotted'). */ +export type LineStyle = string + +/** A line style modifier (e.g. 'straight', 'fill-to-next'). */ +export type LineStyleModifier = string + +/** A graph identifier string. */ +export type GraphId = string + +/** The side of a graph in the main interface. */ +export type GraphSide = string + +/** A graph kind (e.g., 'line', 'bar'). */ +export type GraphKind = string + +/** Describes one dataset to be plotted in a graph. */ +export interface GraphDatasetSpec { + /** The ID of the variable for this dataset, as used in SDEverywhere. */ + readonly varId: OutputVarId + /** + * The name of the variable for this dataset, as used in the modeling tool. + * @hidden This is only included for internal testing use. + */ + readonly varName?: string + /** The source name (e.g. "Ref") if this is from an external data source. */ + readonly externalSourceName?: string + /** The key for the dataset label string (as it appears in the graph legend). */ + readonly labelKey?: StringKey + /** The color of the plot. */ + readonly color: HexColor + /** The line style for the plot. */ + readonly lineStyle: LineStyle + /** The line style modifiers for the plot. */ + readonly lineStyleModifiers?: ReadonlyArray +} + +/** Describes one item in a graph legend. */ +export interface GraphLegendItemSpec { + /** The key for the legend item label string. */ + readonly labelKey: StringKey + /** The color of the legend item. */ + readonly color: HexColor +} + +/** Describes an alternate graph. */ +export interface GraphAlternateSpec { + /** The ID of the alternate graph. */ + readonly id: GraphId + /** The unit system of the alternate graph. */ + readonly unitSystem: UnitSystem +} + +/** Describes a graph that plots one or more model output variables. */ +export interface GraphSpec { + /** The graph ID. */ + readonly id: GraphId + /** The graph kind. */ + readonly kind: GraphKind + /** The key for the graph title string. */ + readonly titleKey: StringKey + /** The key for the graph title as it appears in the miniature graph view (if undefined, use `titleKey`). */ + readonly miniTitleKey?: StringKey + /** The key for the graph title as it appears in the menu (if undefined, use `titleKey`). */ + readonly menuTitleKey?: StringKey + /** The key for the graph description string. */ + readonly descriptionKey?: StringKey + /** Whether the graph is shown on the left or right side of the main interface. */ + readonly side?: GraphSide + /** The unit system for this graph (if undefined, assume metric or international units). */ + readonly unitSystem?: UnitSystem + /** Alternate versions of this graph (e.g. in a different unit system). */ + readonly alternates?: ReadonlyArray + /** The minimum x-axis value. */ + readonly xMin?: number + /** The maximum x-axis value. */ + readonly xMax?: number + /** The key for the x-axis label string. */ + readonly xAxisLabelKey?: StringKey + /** The minimum y-axis value. */ + readonly yMin?: number + /** The maximum y-axis value. */ + readonly yMax?: number + /** + * The "soft" maximum y-axis value. If defined, the y-axis will not shrink smaller + * than this value, but will grow as needed if any y values exceed this value. + */ + readonly ySoftMax?: number + /** The key for the y-axis label string. */ + readonly yAxisLabelKey?: StringKey + /** The string used to format y-axis values. */ + readonly yFormat?: FormatString + /** The datasets to plot in this graph. */ + readonly datasets: ReadonlyArray + /** The items to display in the legend for this graph. */ + readonly legendItems: ReadonlyArray +} diff --git a/packages/plugin-config-csv/src/spec-types/types.ts b/packages/plugin-config-csv/src/spec-types/types.ts new file mode 100644 index 00000000..88e57c0c --- /dev/null +++ b/packages/plugin-config-csv/src/spec-types/types.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2020-2022 Climate Interactive / New Venture Fund + +/** A key used to look up a translated string. */ +export type StringKey = string + +/** A string used to format a number value. */ +export type FormatString = string + +/** The available unit systems. */ +export type UnitSystem = 'metric' | 'us' + +/** An input variable identifier string, as used in SDEverywhere. */ +export type InputVarId = string + +/** An output variable identifier string, as used in SDEverywhere. */ +export type OutputVarId = string diff --git a/packages/plugin-config-csv/src/strings.ts b/packages/plugin-config-csv/src/strings.ts new file mode 100644 index 00000000..97a6f787 --- /dev/null +++ b/packages/plugin-config-csv/src/strings.ts @@ -0,0 +1,196 @@ +// Copyright (c) 2021-2022 Climate Interactive / New Venture Fund + +import sanitizeHtml from 'sanitize-html' + +import type { StringKey } from './spec-types/types' + +interface StringRecord { + key: StringKey + str: string + layout: string + context: string + grouping: string + appendedStringKeys?: string[] +} + +// type LangCode = string +// type StringMap = Map +// type XlatMap = Map + +export class Strings { + private readonly records: Map = new Map() + + add( + key: StringKey, + str: string, + layout: string, + context: string, + grouping?: string, + appendedStringKeys?: string[] + ): StringKey { + checkInvisibleCharacters(str || '') + + if (!key) { + throw new Error(`Must provide a key for the string: ${str}`) + } + + const validKey = /^[0-9a-z_]+$/.test(key) + if (!validKey) { + throw new Error(`String key contains undesirable characters: ${key}`) + } + + if (!layout) { + throw new Error(`Must provide a layout (e.g., 'layout1' or 'not-translated')`) + } + + if (!context) { + throw new Error(`Must provide a context string: key=${key}, string=${str}`) + } + + if (context === 'Core') { + // For core strings from the `strings.csv` file, leave the context empty for now + // (indicating this is a "general" string with no particular context) + context = undefined + } + + if (!grouping) { + // Use 'primary' if grouping is not specified + grouping = 'primary' + } + + // Convert some HTML tags and entities to UTF-8 + if (str) { + str = str.trim() + str = htmlToUtf8(str) + } + + // If the trimmed string is empty, do not create a new key, and return an empty string + if (!str) { + return '' + } + + if (this.records.has(key)) { + // TODO: For now, allow certain keys to appear more than once; we only add the string once + const prefix = key.substring(0, key.indexOf('__')) + switch (prefix) { + case 'input_range': + case 'input_units': + case 'graph_dataset_label': + case 'graph_xaxis_label': + case 'graph_yaxis_label': + break + default: + throw new Error(`More than one string with key=${key}`) + } + } + + // Add the string record + this.records.set(key, { + key, + str, + layout, + context, + grouping, + appendedStringKeys + }) + + return key + } + + // /** + // * Write a `.js` file containing translated strings for each supported language. + // * + // * @param context The build context. + // * @param dstDir The `strings` directory in the core package. + // * @param xlatLangs The set of languages that are configured for translation. + // */ + // writeJsFiles(context: BuildContext, dstDir: string, xlatLangs: Map): void { + // writeLangJsFiles(context, dstDir, this.records, xlatLangs) + // } +} + +function checkInvisibleCharacters(s: string): void { + if (s.includes('\u00a0')) { + const e = s.replace(/\u00a0/g, 'HERE') + throw new Error( + `String contains one or more non-breaking space characters (to fix, replace "HERE" with a normal space):\n ${e}` + ) + } +} + +function htmlSubscriptAndSuperscriptToUtf8(s: string): string { + // Subscripts have a straight mapping in Unicode (U+208x) + s = s.replace(/(\d)<\/sub>/gi, (_match, p1) => String.fromCharCode(0x2080 + Number(p1))) + + // Superscripts don't have a straight mapping, so it's easier to just + // replace the ones we care about. There are some others (12, 18, -5) + // that don't render well when replaced with their Unicode superscript + // equivalents, so we will leave those with HTML `sup` tags. + s = s.replace(/6<\/sup>/gi, '\u2076') + s = s.replace(/9<\/sup>/gi, '\u2079') + + return s +} + +export function htmlToUtf8(orig: string): string { + // Replace common HTML tags and entities with UTF-8 characters + let s = orig + + // Convert `sub` and `sup` tags + s = htmlSubscriptAndSuperscriptToUtf8(s) + + let clean = sanitizeHtml(s, { + allowedTags: ['a', 'b', 'br', 'i', 'em', 'li', 'p', 'strong', 'sub', 'sup', 'ul'], + allowedAttributes: { + a: ['href', 'target', 'rel'] + } + }) + + // XXX: The `sanitize-html` package converts ` ` to the Unicode + // equivalent (`U+00A0`); we will convert it back to ` ` to make + // it more obvious and easier to view in a translation tool + clean = clean.replace(/\u00a0/gi, ' ') + + // if (clean !== s) { + // console.log(`IN: ${orig}`) + // console.log(`O1: ${s}`) + // console.log(`O2: ${clean}\n`) + // } + + return clean +} + +/** + * Generate a string key for the given string by replacing special characters with + * underscores and converting other characters to lowercase. + */ +export function genStringKey(prefix: string, s: string): string { + checkInvisibleCharacters(s) + + let key = s.toLowerCase() + key = key.replace(/ – /g, '_') // e.g. 'Data – Satellite' (with emdash) -> 'data_satellite' + key = key.replace(/ \/ /g, '_per_') // e.g. 'CO2 / TJ' -> 'co2_per_tj' + key = key.replace(/ /g, '_') + key = key.replace('â‚‚', '2') // e.g. 'COâ‚‚' -> 'co2' + key = key.replace('2', '2') // e.g. 'CO2' -> 'co2' + key = key.replace(/\$\//g, 'dollars_per_') // e.g. '$/year' -> 'dollars_per_year' + key = key.replace(/%\//g, 'pct_per_') // e.g. '%/year' -> 'pct_per_year' + key = key.replace(/\//g, '_per_') // e.g. 'CO2/TJ' -> 'co2_per_tj' + key = key.replace(/º/g, 'degrees_') // e.g. 'ºC' -> 'degrees_c' + key = key.replace(/\*/g, '_') // e.g. 'CO2*year' -> 'co2_year' + key = key.replace(/%/g, 'pct') // e.g. '%' -> 'pct' + key = key.replace(/\$/g, 'dollars') // e.g. '$' -> 'dollars' + key = key.replace(/&/g, 'and') // e.g. 'Actions & Outcomes' -> 'actions_and_outcomes' + key = key.replace(/\//g, 'per') // e.g. 'Gigatons CO2/year' -> 'gigatons_co2_per_year' + key = key.replace(/:/g, '') // e.g. 'Net:' -> 'net' + key = key.replace(/\./g, '') // e.g. 'U.S. Units' -> 'us_units' + key = key.replace(/-/g, '_') // e.g. 'some-thing' -> 'some_thing' + key = key.replace(/—/g, '_') // endash to underscore + key = key.replace(/–/g, '_') // emdash to underscore + key = key.replace(/,/g, '') + key = key.replace(/\(/g, '') + key = key.replace(/\)/g, '') + key = key.replace(/\\n/g, '') + key = key.replace(/
/g, '_') + return `${prefix}__${key}` +} diff --git a/packages/plugin-config-csv/src/var-names.ts b/packages/plugin-config-csv/src/var-names.ts index 7e8ec653..763a36f9 100644 --- a/packages/plugin-config-csv/src/var-names.ts +++ b/packages/plugin-config-csv/src/var-names.ts @@ -1,4 +1,4 @@ -// Copyright (c) 2022 Climate Interactive / New Venture Fund. All rights reserved. +// Copyright (c) 2022 Climate Interactive / New Venture Fund export type InputVarId = string export type OutputVarId = string diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30596980..c74b091f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -288,20 +288,24 @@ importers: '@types/dedent': ^0.7.0 '@types/marked': ^4.0.1 '@types/node': ^16.11.7 + '@types/sanitize-html': ^2.6.2 '@types/temp': ^0.9.1 byline: ^5.0.0 csv-parse: ^4.15.4 dedent: ^0.7.0 + sanitize-html: ^2.7.1 temp: ^0.9.4 dependencies: '@sdeverywhere/build': link:../build byline: 5.0.0 csv-parse: 4.16.3 + sanitize-html: 2.7.1 devDependencies: '@types/byline': 4.2.33 '@types/dedent': 0.7.0 '@types/marked': 4.0.6 '@types/node': 16.11.40 + '@types/sanitize-html': 2.6.2 '@types/temp': 0.9.1 dedent: 0.7.0 temp: 0.9.4 @@ -631,6 +635,12 @@ packages: '@types/node': 17.0.42 dev: false + /@types/sanitize-html/2.6.2: + resolution: {integrity: sha512-7Lu2zMQnmHHQGKXVvCOhSziQMpa+R2hMHFefzbYoYMHeaXR0uXqNeOc3JeQQQ8/6Xa2Br/P1IQTLzV09xxAiUQ==} + dependencies: + htmlparser2: 6.1.0 + dev: true + /@types/sass/1.43.1: resolution: {integrity: sha512-BPdoIt1lfJ6B7rw35ncdwBZrAssjcwzI5LByIrYs+tpXlj/CAkuVdRsgZDdP4lq5EjyWzwxZCqAoFyHKFwp32g==} dependencies: @@ -1206,10 +1216,36 @@ packages: resolution: {integrity: sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==} dev: true + /dom-serializer/1.4.1: + resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + entities: 2.2.0 + + /domelementtype/2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + /domhandler/4.3.1: + resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} + engines: {node: '>= 4'} + dependencies: + domelementtype: 2.3.0 + + /domutils/2.8.0: + resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} + dependencies: + dom-serializer: 1.4.1 + domelementtype: 2.3.0 + domhandler: 4.3.1 + /emoji-regex/8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} dev: false + /entities/2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + /error-ex/1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} dependencies: @@ -1458,7 +1494,6 @@ packages: /escape-string-regexp/4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - dev: true /eslint-config-prettier/8.5.0_eslint@8.17.0: resolution: {integrity: sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q==} @@ -1937,6 +1972,14 @@ packages: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} dev: true + /htmlparser2/6.1.0: + resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==} + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + domutils: 2.8.0 + entities: 2.2.0 + /human-signals/2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -2082,6 +2125,11 @@ packages: engines: {node: '>=8'} dev: false + /is-plain-object/5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + dev: false + /is-promise/2.2.2: resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} dev: true @@ -2503,6 +2551,10 @@ packages: json-parse-better-errors: 1.0.2 dev: true + /parse-srcset/1.0.2: + resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + dev: false + /path-exists/5.0.0: resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2835,6 +2887,17 @@ packages: rimraf: 2.7.1 dev: true + /sanitize-html/2.7.1: + resolution: {integrity: sha512-oOpe8l4J8CaBk++2haoN5yNI5beekjuHv3JRPKUx/7h40Rdr85pemn4NkvUB3TcBP7yjat574sPlcMAyv4UQig==} + dependencies: + deepmerge: 4.2.2 + escape-string-regexp: 4.0.0 + htmlparser2: 6.1.0 + is-plain-object: 5.0.0 + parse-srcset: 1.0.2 + postcss: 8.4.14 + dev: false + /sass/1.52.3: resolution: {integrity: sha512-LNNPJ9lafx+j1ArtA7GyEJm9eawXN8KlA1+5dF6IZyoONg1Tyo/g+muOsENWJH/2Q1FHbbV4UwliU0cXMa/VIA==} engines: {node: '>=12.0.0'} From d83380e820cb6a142830b28c11e5e8dfad07bb97 Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Thu, 8 Sep 2022 22:00:42 -0700 Subject: [PATCH 04/24] feat: read inputs.csv --- .../src/__tests__/config1/inputs.csv | 5 +- .../src/__tests__/config1/model.csv | 2 +- packages/plugin-config-csv/src/context.ts | 7 +- .../plugin-config-csv/src/gen-config-specs.ts | 20 +- packages/plugin-config-csv/src/gen-graphs.ts | 8 +- packages/plugin-config-csv/src/gen-inputs.ts | 281 ++++++++++++++++++ .../plugin-config-csv/src/processor.spec.ts | 51 +++- packages/plugin-config-csv/src/spec-types.ts | 202 +++++++++++++ .../src/spec-types/graphs.ts | 103 ------- .../plugin-config-csv/src/spec-types/types.ts | 16 - packages/plugin-config-csv/src/strings.ts | 7 +- 11 files changed, 559 insertions(+), 143 deletions(-) create mode 100644 packages/plugin-config-csv/src/gen-inputs.ts create mode 100644 packages/plugin-config-csv/src/spec-types.ts delete mode 100644 packages/plugin-config-csv/src/spec-types/graphs.ts delete mode 100644 packages/plugin-config-csv/src/spec-types/types.ts diff --git a/packages/plugin-config-csv/src/__tests__/config1/inputs.csv b/packages/plugin-config-csv/src/__tests__/config1/inputs.csv index 3c41d920..0fe5d2b0 100644 --- a/packages/plugin-config-csv/src/__tests__/config1/inputs.csv +++ b/packages/plugin-config-csv/src/__tests__/config1/inputs.csv @@ -1 +1,4 @@ -id,input type,viewid,varname,label,view level,group name,slider min,slider max,slider/switch default,slider step,units,format,reversed,range 2 start,range 3 start,range 4 start,range 5 start,range 1 label,range 2 label,range 3 label,range 4 label,range 5 label,enabled value,disabled value,controlled input ids,listing label +id,input type,viewid,varname,label,view level,group name,slider min,slider max,slider/switch default,slider step,units,format,reversed,range 2 start,range 3 start,range 4 start,range 5 start,range 1 label,range 2 label,range 3 label,range 4 label,range 5 label,enabled value,disabled value,controlled input ids,listing label,description +1,slider,v1,Input A,Slider A Label,,Input Group 1,-50,50,0,1,%,,,-25,-10,10,25,,lowest,low,status quo,high,highest,,,,This is a description of Slider A +2,slider,v1,Input B,Slider B Label,,Input Group 1,-50,50,0,1,%,,,-25,-10,10,25,,lowest,low,status quo,high,highest,,,,This is a description of Slider B +3,switch,v1,Input C,Switch C Label,,Input Group 1,,,0,0,,,,,,,,,,,,,1,0,|,, diff --git a/packages/plugin-config-csv/src/__tests__/config1/model.csv b/packages/plugin-config-csv/src/__tests__/config1/model.csv index 295e28ad..00771047 100644 --- a/packages/plugin-config-csv/src/__tests__/config1/model.csv +++ b/packages/plugin-config-csv/src/__tests__/config1/model.csv @@ -1,2 +1,2 @@ -model start time,model end time,graph min time,graph max time +model start time,model end time,graph default min time,graph default max time 0,200,0,200 diff --git a/packages/plugin-config-csv/src/context.ts b/packages/plugin-config-csv/src/context.ts index 04562078..0803dac4 100644 --- a/packages/plugin-config-csv/src/context.ts +++ b/packages/plugin-config-csv/src/context.ts @@ -7,12 +7,11 @@ import parseCsv from 'csv-parse/lib/sync.js' import type { BuildContext, InputSpec, LogLevel, OutputSpec } from '@sdeverywhere/build' +import type { HexColor } from './spec-types' import { Strings } from './strings' import type { InputVarId, OutputVarId } from './var-names' import { sdeNameForVensimVarName } from './var-names' -import type { HexColor } from './spec-types/graphs' - export type CsvRow = { [key: string]: string } export type ColorId = string @@ -140,8 +139,8 @@ export function createConfigContext(buildContext: BuildContext, configDir: strin const modelCsv = readConfigCsvFile(configDir, 'model')[0] const modelStartTime = Number(modelCsv['model start time']) const modelEndTime = Number(modelCsv['model end time']) - const graphDefaultMinTime = Number(modelCsv['graph min time']) - const graphDefaultMaxTime = Number(modelCsv['graph max time']) + const graphDefaultMinTime = Number(modelCsv['graph default min time']) + const graphDefaultMaxTime = Number(modelCsv['graph default max time']) // Read the static strings from `strings.csv` const strings = readStringsCsv(configDir) diff --git a/packages/plugin-config-csv/src/gen-config-specs.ts b/packages/plugin-config-csv/src/gen-config-specs.ts index b3c6a671..ba9a0e37 100644 --- a/packages/plugin-config-csv/src/gen-config-specs.ts +++ b/packages/plugin-config-csv/src/gen-config-specs.ts @@ -2,11 +2,12 @@ import type { ConfigContext } from './context' import { generateGraphSpecs } from './gen-graphs' -import type { GraphId, GraphSpec } from './spec-types/graphs' +import { generateInputsConfig } from './gen-inputs' +import type { GraphId, GraphSpec, InputId, InputSpec } from './spec-types' export interface ConfigSpecs { graphSpecs: Map - // inputSpecs: Map + inputSpecs: Map } /** @@ -18,9 +19,9 @@ export function generateConfigSpecs(context: ConfigContext): ConfigSpecs { context.log('verbose', ' Reading graph specs') const graphSpecs = generateGraphSpecs(context) - // // Convert `inputs.csv` to input specs - // context.log('verbose', ' Reading input specs') - // const inputsConfig = generateInputsConfig(context) + // Convert `inputs.csv` to input specs + context.log('verbose', ' Reading input specs') + const inputSpecs = generateInputsConfig(context) // Include extra output variables that should be included in the generated // model even though they are not referenced in any graph specs @@ -34,8 +35,8 @@ export function generateConfigSpecs(context: ConfigContext): ConfigSpecs { } return { - graphSpecs - // inputSpecs + graphSpecs, + inputSpecs } } @@ -52,8 +53,7 @@ export function writeConfigSpecs(context: ConfigContext, config: ConfigSpecs, ds emit('// This file is generated by `@sdeverywhere/plugin-config-csv`; do not edit manually!') emit('') - emit(`import type { GraphSpec } from 'TODO'`) - // emit(`import type { InputSpec } from 'TODO'`) + emit(`import type { GraphSpec, InputSpec } from './spec-types'`) // eslint-disable-next-line @typescript-eslint/no-explicit-any function emitArray(type: string, values: Iterable): void { @@ -65,7 +65,7 @@ export function writeConfigSpecs(context: ConfigContext, config: ConfigSpecs, ds } emitArray('GraphSpec', config.graphSpecs.values()) - // emitArray('InputSpec', config.inputSpecs.values()) + emitArray('InputSpec', config.inputSpecs.values()) // Write the `config-specs.ts` file context.writeStagedFile('config', dstDir, 'config-specs.ts', tsContent) diff --git a/packages/plugin-config-csv/src/gen-graphs.ts b/packages/plugin-config-csv/src/gen-graphs.ts index b840e94f..e4636bbf 100644 --- a/packages/plugin-config-csv/src/gen-graphs.ts +++ b/packages/plugin-config-csv/src/gen-graphs.ts @@ -2,7 +2,6 @@ import type { ConfigContext, CsvRow } from './context' import { optionalNumber, optionalString } from './read-config' - import type { GraphAlternateSpec, GraphDatasetSpec, @@ -12,9 +11,10 @@ import type { GraphSide, GraphSpec, LineStyle, - LineStyleModifier -} from './spec-types/graphs' -import type { StringKey, UnitSystem } from './spec-types/types' + LineStyleModifier, + StringKey, + UnitSystem +} from './spec-types' import { genStringKey, htmlToUtf8 } from './strings' import { sdeNameForVensimVarName } from './var-names' diff --git a/packages/plugin-config-csv/src/gen-inputs.ts b/packages/plugin-config-csv/src/gen-inputs.ts new file mode 100644 index 00000000..e23086b8 --- /dev/null +++ b/packages/plugin-config-csv/src/gen-inputs.ts @@ -0,0 +1,281 @@ +// Copyright (c) 2022 Climate Interactive / New Venture Fund + +import type { ConfigContext, CsvRow } from './context' +import { optionalNumber, optionalString } from './read-config' +import type { InputId, InputSpec, SliderSpec, StringKey, SwitchSpec } from './spec-types' +import { genStringKey, htmlToUtf8 } from './strings' +import { sdeNameForVensimVarName } from './var-names' + +// TODO: For now, all strings use the same "layout" specifier; this could be customized +// to provide a "maximum length" hint for a group of strings to the translation tool +const layout = 'default' + +/** + * Convert the `config/inputs.csv` file to config specs that can be used in + * the core package. + */ +export function generateInputsConfig(context: ConfigContext): Map { + // Convert `inputs.csv` to input specs + const inputsCsv = context.readConfigCsvFile('inputs') + const inputSpecs: Map = new Map() + for (const row of inputsCsv) { + const spec = inputSpecFromCsv(row, context) + if (spec) { + inputSpecs.set(spec.id, spec) + } + } + + return inputSpecs +} + +function inputSpecFromCsv(r: CsvRow, context: ConfigContext): InputSpec | undefined { + const strings = context.strings + + function requiredString(key: string): string { + const value = r[key] + if (value === undefined || typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`Must specify '${key}' for input ${r.id}`) + } + return value + } + + function requiredNumber(key: string): number { + const stringValue = requiredString(key) + const numValue = Number(stringValue) + if (numValue === undefined) { + throw new Error(`Must specify numeric '${key}' for input ${r.id}`) + } + return numValue + } + + // Extract required fields + const inputIdParts = requiredString('id').split(';') + const inputId = inputIdParts[0] + const viewId = optionalString(r['viewid']) + const label = optionalString(r['label']) || '' + const inputType = requiredString('input type') + + // Skip rows that have an empty `viewid` value; this can be used to omit inputs + // from the product until they've been fully reviewed and approved + if (!viewId) { + context.log('info', `Skipping input ${inputId} (${label})`) + return undefined + } + + // Extract optional fields + const description = optionalString(r['description']) + + // Helper that creates a string key prefix + const key = (kind: string) => `input_${inputId.padStart(3, '0')}_${kind}` + + // For now, use the group name defined in `inputs.csv` + const groupTitle = optionalString(r['group name']) + if (!groupTitle) { + throw new Error(`Must specify 'group name' for input ${inputId}`) + } + const groupTitleKey = genStringKey('input_group_title', groupTitle) + strings.add(groupTitleKey, groupTitle, layout, 'Input Group Title') + + let typeLabel: string + switch (inputType) { + case 'slider': + typeLabel = 'Slider' + break + case 'switch': + typeLabel = 'Switch' + break + case 'checkbox': + typeLabel = 'Checkbox' + break + case 'checkbox group': + typeLabel = 'Checkbox Group' + break + default: + throw new Error(`Unexpected input type ${inputType}`) + } + + // Helper that creates a string context + const strCtxt = (kind: string) => { + const labelText = htmlToUtf8(label).replace('&', '&') + return `${typeLabel} ${kind}: ${groupTitle} > ${labelText}` + } + + const labelKey = strings.add(key('label'), label, layout, strCtxt('Label')) + + const listingLabel = optionalString(r['listing label']) + let listingLabelKey: StringKey + if (listingLabel) { + listingLabelKey = strings.add(key('action_label'), listingLabel, layout, strCtxt('Action Label')) + } + + let descriptionKey: StringKey + if (description) { + descriptionKey = strings.add(key('description'), description, layout, strCtxt('Description'), 'slider-descriptions') + } + + // Converts a slider row in `inputs.csv` to a `SliderSpec` + function sliderSpecFromCsv(): SliderSpec { + const varName = requiredString('varname') + const varId = sdeNameForVensimVarName(varName) + + const defaultValue = requiredNumber('slider/switch default') + const minValue = requiredNumber('slider min') + const maxValue = requiredNumber('slider max') + const step = requiredNumber('slider step') + const reversed = optionalString(r['reversed']) === 'yes' + + if (defaultValue < minValue || defaultValue > maxValue) { + let e = `Default value for slider ${inputId} is out of range: ` + e += `default=${defaultValue} min=${minValue} max=${maxValue}` + throw new Error(e) + } + context.addInputVariable(varName, defaultValue, minValue, maxValue) + + const format = optionalString(r['format']) || '.0f' + + const units = optionalString(r['units']) + let unitsKey: StringKey + if (units) { + unitsKey = strings.add(genStringKey('input_units', units), units, layout, 'Slider Units') + } + + const rangeInfo = getSliderRangeInfo(r, maxValue, context) + const rangeLabelKeys = rangeInfo.labelKeys + const rangeDividers = rangeInfo.dividers + + return { + kind: 'slider', + id: inputId, + varId, + varName, + defaultValue, + minValue, + maxValue, + step, + reversed, + labelKey, + listingLabelKey, + descriptionKey, + unitsKey, + rangeLabelKeys, + rangeDividers, + format + } + } + + // Converts a switch row in `inputs.csv` to a `SwitchSpec` + function switchSpecFromCsv(): SwitchSpec { + const varName = requiredString('varname') + const varId = sdeNameForVensimVarName(varName) + + const onValue = requiredNumber('enabled value') + const offValue = requiredNumber('disabled value') + const defaultValue = requiredNumber('slider/switch default') + if (defaultValue !== onValue && defaultValue !== offValue) { + throw new Error( + `Invalid default value for switch ${inputId}: off=${offValue} on=${onValue} default=${defaultValue}` + ) + } + + const minValue = Math.min(offValue, onValue) + const maxValue = Math.max(offValue, onValue) + context.addInputVariable(varName, defaultValue, minValue, maxValue) + + // The `controlled input ids` field dictates which rows are active + // when this switch is on or off. Examples of the format of this field: + // 1;2;3|4;5;6 + // 1|2;3;4;5 + // |1 + // On the left side of the '|' are the rows that are active when the + // switch is in an off position, and on the right side are the rows + // that are active when the switch is in an on position. Usually the + // "active when off" rows are above the switch in the UI, and the + // "active when on" rows are below the switch. + const controlledInputIds = requiredString('controlled input ids') + const controlledParts = controlledInputIds.split('|') + const rowsActiveWhenOff = controlledParts[0].split(';').filter(id => id.trim().length > 0) + const rowsActiveWhenOn = controlledParts[1].split(';').filter(id => id.trim().length > 0) + + return { + kind: 'switch', + id: inputId, + varId, + varName, + labelKey, + listingLabelKey, + descriptionKey, + defaultValue, + offValue, + onValue, + slidersActiveWhenOff: rowsActiveWhenOff, + slidersActiveWhenOn: rowsActiveWhenOn + } + } + + // Call a different converter function depending on the input type + let inputSpec: SliderSpec | SwitchSpec + switch (inputType) { + case 'slider': { + inputSpec = sliderSpecFromCsv() + break + } + case 'switch': + case 'checkbox': { + inputSpec = switchSpecFromCsv() + break + } + case 'checkbox group': + // TODO + // XXX: For now, we specify the checkbox IDs as a semicolon separated list + // in the "varname" cell + // const checkboxIds = row['varname'].split(';') + break + default: + throw new Error(`Unexpected input type ${inputType}`) + } + + return inputSpec +} + +interface SliderRangeInfo { + labelKeys: StringKey[] + dividers: number[] +} + +function getSliderRangeInfo(r: CsvRow, maxValue: number, context: ConfigContext): SliderRangeInfo { + const strings = context.strings + const labelKeys: StringKey[] = [] + const dividers: number[] = [] + + // Get all labels to determine the number of ranges + let rangeNum = 1 + while (rangeNum <= 5) { + const label = optionalString(r[`range ${rangeNum} label`]) + if (!label) { + break + } + const labelKey = strings.add(genStringKey('input_range', label), label, layout, 'Slider Range Label') + if (!labelKey) { + break + } + labelKeys.push(labelKey) + rangeNum++ + } + + // Find dividing points between ranges; the absence of a final dividing point + // indicates the use of discrete values + const numRanges = rangeNum - 1 + for (rangeNum = 2; rangeNum <= numRanges; rangeNum++) { + let divider = optionalNumber(r[`range ${rangeNum} start`]) + if (divider === undefined) { + // Fall back on the slider max value when a divider is missing + divider = maxValue + } + dividers.push(divider) + } + + return { + labelKeys, + dividers + } +} diff --git a/packages/plugin-config-csv/src/processor.spec.ts b/packages/plugin-config-csv/src/processor.spec.ts index 9bae9248..3c26564e 100644 --- a/packages/plugin-config-csv/src/processor.spec.ts +++ b/packages/plugin-config-csv/src/processor.spec.ts @@ -60,7 +60,7 @@ export const outputVarIds: string[] = [] const configSpecs1 = `\ // This file is generated by \`@sdeverywhere/plugin-config-csv\`; do not edit manually! -import type { GraphSpec } from './spec-types' +import type { GraphSpec, InputSpec } from './spec-types' export const graphSpecs: GraphSpec[] = [ { @@ -103,6 +103,55 @@ export const graphSpecs: GraphSpec[] = [ ] } ] + +export const inputSpecs: InputSpec[] = [ + { + "kind": "slider", + "id": "1", + "varId": "_input_a", + "varName": "Input A", + "defaultValue": 0, + "minValue": -50, + "maxValue": 50, + "step": 1, + "reversed": false, + "labelKey": "input_001_label", + "descriptionKey": "input_001_description", + "unitsKey": "input_units__pct", + "rangeLabelKeys": [], + "rangeDividers": [], + "format": ".0f" + }, + { + "kind": "slider", + "id": "2", + "varId": "_input_b", + "varName": "Input B", + "defaultValue": 0, + "minValue": -50, + "maxValue": 50, + "step": 1, + "reversed": false, + "labelKey": "input_002_label", + "descriptionKey": "input_002_description", + "unitsKey": "input_units__pct", + "rangeLabelKeys": [], + "rangeDividers": [], + "format": ".0f" + }, + { + "kind": "switch", + "id": "3", + "varId": "_input_c", + "varName": "Input C", + "labelKey": "input_003_label", + "defaultValue": 0, + "offValue": 0, + "onValue": 1, + "slidersActiveWhenOff": [], + "slidersActiveWhenOn": [] + } +] ` describe('configProcessor', () => { diff --git a/packages/plugin-config-csv/src/spec-types.ts b/packages/plugin-config-csv/src/spec-types.ts new file mode 100644 index 00000000..4dcda17e --- /dev/null +++ b/packages/plugin-config-csv/src/spec-types.ts @@ -0,0 +1,202 @@ +// Copyright (c) 2022 Climate Interactive / New Venture Fund + +// +// Common types +// + +/** A key used to look up a translated string. */ +export type StringKey = string + +/** A string used to format a number value. */ +export type FormatString = string + +/** The available unit systems. */ +export type UnitSystem = 'metric' | 'us' + +/** An input variable identifier string, as used in SDEverywhere. */ +export type InputVarId = string + +/** An output variable identifier string, as used in SDEverywhere. */ +export type OutputVarId = string + +// +// Input-related types +// + +/** An input (e.g., slider or switch) identifier. */ +export type InputId = string + +/** Describes a slider that controls an model input variable. */ +export interface SliderSpec { + readonly kind: 'slider' + /** The input ID for this slider. */ + readonly id: InputId + /** The ID of the associated input variable, as used in SDEverywhere. */ + readonly varId: InputVarId + /** + * The name of the associated input variable, as used in the modeling tool. + * @hidden This is only included for internal testing use. + */ + readonly varName?: string + /** The default value of the variable controlled by the slider. */ + readonly defaultValue: number + /** The minimum value of the variable controlled by the slider. */ + readonly minValue: number + /** The maximum value of the variable controlled by the slider. */ + readonly maxValue: number + /** The size of each step/increment between stops. */ + readonly step: number + /** Whether to display the slider with the endpoints reversed. */ + readonly reversed: boolean + /** The key for the slider label string. */ + readonly labelKey: StringKey + /** The key for the label string when this slider appears in "Actions & Outcomes". */ + readonly listingLabelKey?: StringKey + /** The key for the slider description string. */ + readonly descriptionKey?: StringKey + /** The key for the units string. */ + readonly unitsKey?: StringKey + /** The keys for the slider range label strings. */ + readonly rangeLabelKeys: ReadonlyArray + /** The values that mark the ranges within the slider. */ + readonly rangeDividers: ReadonlyArray + /** The string used to format the slider value. */ + readonly format?: FormatString +} + +/** Describes an on/off switch that controls an input variable. */ +export interface SwitchSpec { + readonly kind: 'switch' + /** The input ID for this switch. */ + readonly id: InputId + /** The ID of the associated input variable, as used in SDEverywhere. */ + readonly varId: InputVarId + /** + * The name of the associated input variable, as used in the modeling tool. + * @hidden This is only included for internal testing use. + */ + readonly varName?: string + /** The default value of the variable controlled by the switch. */ + readonly defaultValue: number + /** The value of the variable when this switch is in an "off" state. */ + readonly offValue: number + /** The value of the variable when this switch is in an "on" state. */ + readonly onValue: number + /** The key for the switch label string. */ + readonly labelKey: StringKey + /** The key for the label string when this switch appears in "Actions & Outcomes". */ + readonly listingLabelKey?: StringKey + /** The key for the switch description string. */ + readonly descriptionKey?: StringKey + /** The set of sliders that will be active/enabled when this switch is "off". */ + readonly slidersActiveWhenOff: ReadonlyArray + /** The set of sliders that will be active/enabled when this switch is "on". */ + readonly slidersActiveWhenOn: ReadonlyArray +} + +/** An input is either a slider or a switch (with associated sliders). */ +export type InputSpec = SliderSpec | SwitchSpec + +// +// Graph-related types +// + +/** A hex color code (e.g. '#ff0033'). */ +export type HexColor = string + +/** A line style specifier (e.g., 'line', 'dotted'). */ +export type LineStyle = string + +/** A line style modifier (e.g. 'straight', 'fill-to-next'). */ +export type LineStyleModifier = string + +/** A graph identifier string. */ +export type GraphId = string + +/** The side of a graph in the main interface. */ +export type GraphSide = string + +/** A graph kind (e.g., 'line', 'bar'). */ +export type GraphKind = string + +/** Describes one dataset to be plotted in a graph. */ +export interface GraphDatasetSpec { + /** The ID of the variable for this dataset, as used in SDEverywhere. */ + readonly varId: OutputVarId + /** + * The name of the variable for this dataset, as used in the modeling tool. + * @hidden This is only included for internal testing use. + */ + readonly varName?: string + /** The source name (e.g. "Ref") if this is from an external data source. */ + readonly externalSourceName?: string + /** The key for the dataset label string (as it appears in the graph legend). */ + readonly labelKey?: StringKey + /** The color of the plot. */ + readonly color: HexColor + /** The line style for the plot. */ + readonly lineStyle: LineStyle + /** The line style modifiers for the plot. */ + readonly lineStyleModifiers?: ReadonlyArray +} + +/** Describes one item in a graph legend. */ +export interface GraphLegendItemSpec { + /** The key for the legend item label string. */ + readonly labelKey: StringKey + /** The color of the legend item. */ + readonly color: HexColor +} + +/** Describes an alternate graph. */ +export interface GraphAlternateSpec { + /** The ID of the alternate graph. */ + readonly id: GraphId + /** The unit system of the alternate graph. */ + readonly unitSystem: UnitSystem +} + +/** Describes a graph that plots one or more model output variables. */ +export interface GraphSpec { + /** The graph ID. */ + readonly id: GraphId + /** The graph kind. */ + readonly kind: GraphKind + /** The key for the graph title string. */ + readonly titleKey: StringKey + /** The key for the graph title as it appears in the miniature graph view (if undefined, use `titleKey`). */ + readonly miniTitleKey?: StringKey + /** The key for the graph title as it appears in the menu (if undefined, use `titleKey`). */ + readonly menuTitleKey?: StringKey + /** The key for the graph description string. */ + readonly descriptionKey?: StringKey + /** Whether the graph is shown on the left or right side of the main interface. */ + readonly side?: GraphSide + /** The unit system for this graph (if undefined, assume metric or international units). */ + readonly unitSystem?: UnitSystem + /** Alternate versions of this graph (e.g. in a different unit system). */ + readonly alternates?: ReadonlyArray + /** The minimum x-axis value. */ + readonly xMin?: number + /** The maximum x-axis value. */ + readonly xMax?: number + /** The key for the x-axis label string. */ + readonly xAxisLabelKey?: StringKey + /** The minimum y-axis value. */ + readonly yMin?: number + /** The maximum y-axis value. */ + readonly yMax?: number + /** + * The "soft" maximum y-axis value. If defined, the y-axis will not shrink smaller + * than this value, but will grow as needed if any y values exceed this value. + */ + readonly ySoftMax?: number + /** The key for the y-axis label string. */ + readonly yAxisLabelKey?: StringKey + /** The string used to format y-axis values. */ + readonly yFormat?: FormatString + /** The datasets to plot in this graph. */ + readonly datasets: ReadonlyArray + /** The items to display in the legend for this graph. */ + readonly legendItems: ReadonlyArray +} diff --git a/packages/plugin-config-csv/src/spec-types/graphs.ts b/packages/plugin-config-csv/src/spec-types/graphs.ts deleted file mode 100644 index 94c5db8d..00000000 --- a/packages/plugin-config-csv/src/spec-types/graphs.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright (c) 2020-2022 Climate Interactive / New Venture Fund - -import type { FormatString, OutputVarId, StringKey, UnitSystem } from './types' - -/** A hex color code (e.g. '#ff0033'). */ -export type HexColor = string - -/** A line style specifier (e.g., 'line', 'dotted'). */ -export type LineStyle = string - -/** A line style modifier (e.g. 'straight', 'fill-to-next'). */ -export type LineStyleModifier = string - -/** A graph identifier string. */ -export type GraphId = string - -/** The side of a graph in the main interface. */ -export type GraphSide = string - -/** A graph kind (e.g., 'line', 'bar'). */ -export type GraphKind = string - -/** Describes one dataset to be plotted in a graph. */ -export interface GraphDatasetSpec { - /** The ID of the variable for this dataset, as used in SDEverywhere. */ - readonly varId: OutputVarId - /** - * The name of the variable for this dataset, as used in the modeling tool. - * @hidden This is only included for internal testing use. - */ - readonly varName?: string - /** The source name (e.g. "Ref") if this is from an external data source. */ - readonly externalSourceName?: string - /** The key for the dataset label string (as it appears in the graph legend). */ - readonly labelKey?: StringKey - /** The color of the plot. */ - readonly color: HexColor - /** The line style for the plot. */ - readonly lineStyle: LineStyle - /** The line style modifiers for the plot. */ - readonly lineStyleModifiers?: ReadonlyArray -} - -/** Describes one item in a graph legend. */ -export interface GraphLegendItemSpec { - /** The key for the legend item label string. */ - readonly labelKey: StringKey - /** The color of the legend item. */ - readonly color: HexColor -} - -/** Describes an alternate graph. */ -export interface GraphAlternateSpec { - /** The ID of the alternate graph. */ - readonly id: GraphId - /** The unit system of the alternate graph. */ - readonly unitSystem: UnitSystem -} - -/** Describes a graph that plots one or more model output variables. */ -export interface GraphSpec { - /** The graph ID. */ - readonly id: GraphId - /** The graph kind. */ - readonly kind: GraphKind - /** The key for the graph title string. */ - readonly titleKey: StringKey - /** The key for the graph title as it appears in the miniature graph view (if undefined, use `titleKey`). */ - readonly miniTitleKey?: StringKey - /** The key for the graph title as it appears in the menu (if undefined, use `titleKey`). */ - readonly menuTitleKey?: StringKey - /** The key for the graph description string. */ - readonly descriptionKey?: StringKey - /** Whether the graph is shown on the left or right side of the main interface. */ - readonly side?: GraphSide - /** The unit system for this graph (if undefined, assume metric or international units). */ - readonly unitSystem?: UnitSystem - /** Alternate versions of this graph (e.g. in a different unit system). */ - readonly alternates?: ReadonlyArray - /** The minimum x-axis value. */ - readonly xMin?: number - /** The maximum x-axis value. */ - readonly xMax?: number - /** The key for the x-axis label string. */ - readonly xAxisLabelKey?: StringKey - /** The minimum y-axis value. */ - readonly yMin?: number - /** The maximum y-axis value. */ - readonly yMax?: number - /** - * The "soft" maximum y-axis value. If defined, the y-axis will not shrink smaller - * than this value, but will grow as needed if any y values exceed this value. - */ - readonly ySoftMax?: number - /** The key for the y-axis label string. */ - readonly yAxisLabelKey?: StringKey - /** The string used to format y-axis values. */ - readonly yFormat?: FormatString - /** The datasets to plot in this graph. */ - readonly datasets: ReadonlyArray - /** The items to display in the legend for this graph. */ - readonly legendItems: ReadonlyArray -} diff --git a/packages/plugin-config-csv/src/spec-types/types.ts b/packages/plugin-config-csv/src/spec-types/types.ts deleted file mode 100644 index 88e57c0c..00000000 --- a/packages/plugin-config-csv/src/spec-types/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) 2020-2022 Climate Interactive / New Venture Fund - -/** A key used to look up a translated string. */ -export type StringKey = string - -/** A string used to format a number value. */ -export type FormatString = string - -/** The available unit systems. */ -export type UnitSystem = 'metric' | 'us' - -/** An input variable identifier string, as used in SDEverywhere. */ -export type InputVarId = string - -/** An output variable identifier string, as used in SDEverywhere. */ -export type OutputVarId = string diff --git a/packages/plugin-config-csv/src/strings.ts b/packages/plugin-config-csv/src/strings.ts index 97a6f787..9cf88fbb 100644 --- a/packages/plugin-config-csv/src/strings.ts +++ b/packages/plugin-config-csv/src/strings.ts @@ -2,7 +2,7 @@ import sanitizeHtml from 'sanitize-html' -import type { StringKey } from './spec-types/types' +import type { StringKey } from './spec-types' interface StringRecord { key: StringKey @@ -73,11 +73,12 @@ export class Strings { // TODO: For now, allow certain keys to appear more than once; we only add the string once const prefix = key.substring(0, key.indexOf('__')) switch (prefix) { - case 'input_range': - case 'input_units': case 'graph_dataset_label': case 'graph_xaxis_label': case 'graph_yaxis_label': + case 'input_group_title': + case 'input_range': + case 'input_units': break default: throw new Error(`More than one string with key=${key}`) From 02a3640416def06aaef26c80cc30696d42a1a20e Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Fri, 9 Sep 2022 12:25:49 -0700 Subject: [PATCH 05/24] fix: generate base strings and include i/o vars in returned ModelSpec --- examples/sir/.gitignore | 1 - examples/sir/config/graphs.csv | 2 + examples/sir/config/inputs.csv | 5 +- examples/sir/config/model.csv | 4 +- examples/sir/model/sir.check.yaml | 13 ++ examples/sir/packages/sir-app/.gitignore | 1 + examples/sir/packages/sir-app/src/index.js | 2 +- examples/sir/packages/sir-app/vite.config.js | 3 +- examples/sir/packages/sir-core/.gitignore | 2 + packages/plugin-config-csv/src/context.ts | 5 +- packages/plugin-config-csv/src/gen-inputs.ts | 2 +- .../plugin-config-csv/src/processor.spec.ts | 29 +++- packages/plugin-config-csv/src/processor.ts | 42 +---- packages/plugin-config-csv/src/strings.ts | 157 ++++++++++++++++-- 14 files changed, 206 insertions(+), 62 deletions(-) create mode 100644 examples/sir/model/sir.check.yaml create mode 100644 examples/sir/packages/sir-app/.gitignore create mode 100644 examples/sir/packages/sir-core/.gitignore diff --git a/examples/sir/.gitignore b/examples/sir/.gitignore index 3aa52f1b..3c0f0280 100644 --- a/examples/sir/.gitignore +++ b/examples/sir/.gitignore @@ -1,2 +1 @@ sde-prep -**/generated diff --git a/examples/sir/config/graphs.csv b/examples/sir/config/graphs.csv index 1d20bde6..f4a67ccd 100644 --- a/examples/sir/config/graphs.csv +++ b/examples/sir/config/graphs.csv @@ -1 +1,3 @@ id,side,parent menu,graph title,menu title,mini title,vensim graph,kind,modes,units,alternate,unused 1,unused 2,unused 3,x axis min,x axis max,x axis label,unused 4,unused 5,y axis min,y axis max,y axis soft max,y axis label,y axis format,unused 6,unused 7,plot 1 variable,plot 1 source,plot 1 style,plot 1 label,plot 1 color,plot 1 unused 1,plot 1 unused 2,plot 2 variable,plot 2 source,plot 2 style,plot 2 label,plot 2 color,plot 2 unused 1,plot 2 unused 2,plot 3 variable,plot 3 source,plot 3 style,plot 3 label,plot 3 color,plot 3 unused 1,plot 3 unused 2,plot 4 variable,plot 4 source,plot 4 style,plot 4 label,plot 4 color,plot 4 unused 1,plot 4 unused 2,plot 5 variable,plot 5 source,plot 5 style,plot 5 label,plot 5 color,plot 5 unused 1,plot 5 unused 2,plot 6 variable,plot 6 source,plot 6 style,plot 6 label,plot 6 color,plot 6 unused 1,plot 6 unused 2,plot 7 variable,plot 7 source,plot 7 style,plot 7 label,plot 7 color,plot 7 unused 1,plot 7 unused 2,plot 8 variable,plot 8 source,plot 8 style,plot 8 label,plot 8 color,plot 8 unused 1,plot 8 unused 2,plot 9 variable,plot 9 source,plot 9 style,plot 9 label,plot 9 color,plot 9 unused 1,plot 9 unused 2,plot 10 variable,plot 10 source,plot 10 style,plot 10 label,plot 10 color,plot 10 unused 1,plot 10 unused 2,plot 11 variable,plot 11 source,plot 11 style,plot 11 label,plot 11 color,plot 11 unused 1,plot 11 unused 2,plot 12 variable,plot 12 source,plot 12 style,plot 12 label,plot 12 color,plot 12 unused 1,plot 12 unused 2,plot 13 variable,plot 13 source,plot 13 style,plot 13 label,plot 13 color,plot 13 unused 1,plot 13 unused 2,plot 14 variable,plot 14 source,plot 14 style,plot 14 label,plot 14 color,plot 14 unused 1,plot 14 unused 2,plot 15 variable,plot 15 source,plot 15 style,plot 15 label,plot 15 color,plot 15 unused 1,plot 15 unused 2 +1,,Graphs,Infection and Recovery Rates,,,,line,,people/day,,,,,,,,,,,2000,,,,,,Infection Rate,,line,Infection Rate,blue,,,Recovery Rate,,line,Recovery Rate,red,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, +2,,Graphs,Population,,,,line,,people,,,,,,,,,,,12000,,,,,,Susceptible Population S,,line,Susceptible,blue,,,Infectious Population I,,line,Infectious,red,,,Recovered Population R,,line,Recovered,green,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, diff --git a/examples/sir/config/inputs.csv b/examples/sir/config/inputs.csv index 3c41d920..71d1110f 100644 --- a/examples/sir/config/inputs.csv +++ b/examples/sir/config/inputs.csv @@ -1 +1,4 @@ -id,input type,viewid,varname,label,view level,group name,slider min,slider max,slider/switch default,slider step,units,format,reversed,range 2 start,range 3 start,range 4 start,range 5 start,range 1 label,range 2 label,range 3 label,range 4 label,range 5 label,enabled value,disabled value,controlled input ids,listing label +id,input type,viewid,varname,label,view level,group name,slider min,slider max,slider/switch default,slider step,units,format,reversed,range 2 start,range 3 start,range 4 start,range 5 start,range 1 label,range 2 label,range 3 label,range 4 label,range 5 label,enabled value,disabled value,controlled input ids,listing label,description +1,slider,v1,Initial Contact Rate,Initial Contact Rate,,Inputs,0,5,1,0.1,per day,.1f,,,,,,,,,,,,,,, +2,slider,v1,Infectivity i,Infectivity,,Inputs,-2,2,0.1,0.1,probability,.1f,,,,,,,,,,,,,,, +3,slider,v1,Average Duration of Illness d,Average Duration of Illness,,Inputs,0,10,2,1,days,,,,,,,,,,,,,,,, diff --git a/examples/sir/config/model.csv b/examples/sir/config/model.csv index 5f2246ff..00771047 100644 --- a/examples/sir/config/model.csv +++ b/examples/sir/config/model.csv @@ -1,2 +1,2 @@ -startTime,endTime -0,200 +model start time,model end time,graph default min time,graph default max time +0,200,0,200 diff --git a/examples/sir/model/sir.check.yaml b/examples/sir/model/sir.check.yaml new file mode 100644 index 00000000..aa45fdef --- /dev/null +++ b/examples/sir/model/sir.check.yaml @@ -0,0 +1,13 @@ +# yaml-language-server: $schema=../node_modules/@sdeverywhere/plugin-check/node_modules/@sdeverywhere/check-core/schema/check.schema.json + +- describe: Population Variables + tests: + - it: should be non-negative all input scenarios + scenarios: + - preset: matrix + datasets: + - name: Infectious Population I + - name: Recovered Population R + - name: Susceptible Population S + predicates: + - gte: 0 diff --git a/examples/sir/packages/sir-app/.gitignore b/examples/sir/packages/sir-app/.gitignore new file mode 100644 index 00000000..a48cf0de --- /dev/null +++ b/examples/sir/packages/sir-app/.gitignore @@ -0,0 +1 @@ +public diff --git a/examples/sir/packages/sir-app/src/index.js b/examples/sir/packages/sir-app/src/index.js index 2eb73153..4bbea08a 100644 --- a/examples/sir/packages/sir-app/src/index.js +++ b/examples/sir/packages/sir-app/src/index.js @@ -3,7 +3,7 @@ import Slider from 'bootstrap-slider' import 'bootstrap-slider/dist/css/bootstrap-slider.css' import { createModel } from '@core' -import enStrings from '@core/strings/en' +import enStrings from '@core-strings/en' let coreConfig let model diff --git a/examples/sir/packages/sir-app/vite.config.js b/examples/sir/packages/sir-app/vite.config.js index 7f5e1b85..a6f00ce2 100644 --- a/examples/sir/packages/sir-app/vite.config.js +++ b/examples/sir/packages/sir-app/vite.config.js @@ -31,7 +31,8 @@ export default defineConfig(env => { resolve: { alias: { - '@core': resolve(appDir, '..', 'sir-core', 'src') + '@core': resolve(appDir, '..', 'sir-core', 'src'), + '@core-strings': resolve(appDir, '..', 'sir-core', 'strings') } }, diff --git a/examples/sir/packages/sir-core/.gitignore b/examples/sir/packages/sir-core/.gitignore new file mode 100644 index 00000000..2428186c --- /dev/null +++ b/examples/sir/packages/sir-core/.gitignore @@ -0,0 +1,2 @@ +generated +strings diff --git a/packages/plugin-config-csv/src/context.ts b/packages/plugin-config-csv/src/context.ts index 0803dac4..b7b331f3 100644 --- a/packages/plugin-config-csv/src/context.ts +++ b/packages/plugin-config-csv/src/context.ts @@ -128,9 +128,8 @@ export class ConfigContext { }) } - writeStringsFiles(): void { - // const dstDir = corePackageFilePath('strings') - // this.strings.writeJsFiles(this.buildContext, dstDir, xlatLangs) + writeStringsFiles(dstDir: string): void { + this.strings.writeJsFiles(this.buildContext, dstDir /*, xlatLangs*/) } } diff --git a/packages/plugin-config-csv/src/gen-inputs.ts b/packages/plugin-config-csv/src/gen-inputs.ts index e23086b8..529dec79 100644 --- a/packages/plugin-config-csv/src/gen-inputs.ts +++ b/packages/plugin-config-csv/src/gen-inputs.ts @@ -110,7 +110,7 @@ function inputSpecFromCsv(r: CsvRow, context: ConfigContext): InputSpec | undefi let descriptionKey: StringKey if (description) { - descriptionKey = strings.add(key('description'), description, layout, strCtxt('Description'), 'slider-descriptions') + descriptionKey = strings.add(key('description'), description, layout, strCtxt('Description'), 'input-descriptions') } // Converts a slider row in `inputs.csv` to a `SliderSpec` diff --git a/packages/plugin-config-csv/src/processor.spec.ts b/packages/plugin-config-csv/src/processor.spec.ts index 3c26564e..65260d1c 100644 --- a/packages/plugin-config-csv/src/processor.spec.ts +++ b/packages/plugin-config-csv/src/processor.spec.ts @@ -23,7 +23,7 @@ interface TestEnv { async function prepareForBuild(optionsFunc: (corePkgDir: string) => ConfigOptions): Promise { const baseTmpDir = await temp.mkdir('sde-plugin-config-csv') - console.log(baseTmpDir) + // console.log(baseTmpDir) const projDir = joinPath(baseTmpDir, 'proj') await mkdir(projDir) const corePkgDir = joinPath(projDir, 'core-package') @@ -37,7 +37,8 @@ async function prepareForBuild(optionsFunc: (corePkgDir: string) => ConfigOption const buildOptions: BuildOptions = { config, - logLevels: ['info'], + //logLevels: ['info'], + logLevels: [], sdeDir: '', sdeCmdPath: '' } @@ -154,6 +155,24 @@ export const inputSpecs: InputSpec[] = [ ] ` +const enStrings1 = `\ +export default { + "__string_1": "String 1", + "__string_2": "String 2", + "graph_001_title": "Graph 1 Title", + "graph_dataset_label__baseline": "Baseline", + "graph_dataset_label__current_scenario": "Current Scenario", + "graph_xaxis_label__x_axis": "X-Axis", + "graph_yaxis_label__y_axis": "Y-Axis", + "input_001_description": "This is a description of Slider A", + "input_001_label": "Slider A Label", + "input_002_description": "This is a description of Slider B", + "input_002_label": "Slider B Label", + "input_003_label": "Switch C Label", + "input_group_title__input_group_1": "Input Group 1", + "input_units__pct": "%" +}` + describe('configProcessor', () => { beforeAll(() => { temp.track() @@ -192,7 +211,8 @@ describe('configProcessor', () => { const configSpecsFile = joinPath(testEnv.corePkgDir, 'src', 'config', 'generated', 'config-specs.ts') expect(await readFile(configSpecsFile, 'utf8')).toEqual(configSpecs1) - // TODO: Check strings + const enStringsFile = joinPath(testEnv.corePkgDir, 'strings', 'en.js') + expect(await readFile(enStringsFile, 'utf8')).toEqual(enStrings1) }) it('should write to given directories if out paths are provided', async () => { @@ -216,6 +236,7 @@ describe('configProcessor', () => { const configSpecsFile = joinPath(testEnv.corePkgDir, 'cgen', 'config-specs.ts') expect(await readFile(configSpecsFile, 'utf8')).toEqual(configSpecs1) - // TODO: Check strings + const enStringsFile = joinPath(testEnv.corePkgDir, 'sgen', 'en.js') + expect(await readFile(enStringsFile, 'utf8')).toEqual(enStrings1) }) }) diff --git a/packages/plugin-config-csv/src/processor.ts b/packages/plugin-config-csv/src/processor.ts index fc4641de..a051e5bd 100644 --- a/packages/plugin-config-csv/src/processor.ts +++ b/packages/plugin-config-csv/src/processor.ts @@ -3,7 +3,7 @@ import { existsSync } from 'fs' import { join as joinPath } from 'path' -import type { BuildContext, InputSpec, ModelSpec, OutputSpec } from '@sdeverywhere/build' +import type { BuildContext, ModelSpec } from '@sdeverywhere/build' import { createConfigContext } from './context' import { writeModelSpec } from './gen-model-spec' @@ -101,57 +101,29 @@ async function processModelConfig(buildContext: BuildContext, options: ConfigOpt writeModelSpec(context, outModelSpecsDir) } + const configSpecs = generateConfigSpecs(context) if (outConfigSpecsDir) { - const configSpecs = generateConfigSpecs(context) context.log('verbose', ' Writing config specs') writeConfigSpecs(context, configSpecs, outConfigSpecsDir) } if (outStringsDir) { - // context.log('verbose', ' Writing translated strings') - // context.writeTranslationJsFiles(options.outStringsDir) + context.log('verbose', ' Writing strings') + context.writeStringsFiles(outStringsDir) } const t1 = performance.now() const elapsed = ((t1 - t0) / 1000).toFixed(1) context.log('info', `Done generating files (${elapsed}s)`) - // TODO: model.csv + // TODO: List these in model.csv const datFiles: string[] = [] - // export interface InputSpec { - // /** The variable name (as used in the modeling tool). */ - // varName: string - - // /** The default value for the input. */ - // defaultValue: number - - // /** The minimum value for the input. */ - // minValue: number - - // /** The maximum value for the input. */ - // maxValue: number - // } - - // TODO - const inputs: InputSpec[] = [] - - // /** - // * Describes a model output variable. - // */ - // export interface OutputSpec { - // /** The variable name (as used in the modeling tool). */ - // varName: string - // } - - // TODO - const outputs: OutputSpec[] = [] - return { startTime: context.modelStartTime, endTime: context.modelEndTime, - inputs, - outputs, + inputs: context.getOrderedInputs(), + outputs: context.getOrderedOutputs(), datFiles } } diff --git a/packages/plugin-config-csv/src/strings.ts b/packages/plugin-config-csv/src/strings.ts index 9cf88fbb..2dceb591 100644 --- a/packages/plugin-config-csv/src/strings.ts +++ b/packages/plugin-config-csv/src/strings.ts @@ -2,6 +2,8 @@ import sanitizeHtml from 'sanitize-html' +import type { BuildContext } from '@sdeverywhere/build' + import type { StringKey } from './spec-types' interface StringRecord { @@ -13,9 +15,9 @@ interface StringRecord { appendedStringKeys?: string[] } -// type LangCode = string -// type StringMap = Map -// type XlatMap = Map +type LangCode = string +type StringMap = Map +type XlatMap = Map export class Strings { private readonly records: Map = new Map() @@ -98,16 +100,23 @@ export class Strings { return key } - // /** - // * Write a `.js` file containing translated strings for each supported language. - // * - // * @param context The build context. - // * @param dstDir The `strings` directory in the core package. - // * @param xlatLangs The set of languages that are configured for translation. - // */ - // writeJsFiles(context: BuildContext, dstDir: string, xlatLangs: Map): void { - // writeLangJsFiles(context, dstDir, this.records, xlatLangs) - // } + /** + * Write a `.js` file containing translated strings for each supported language. + * + * @param context The build context. + * @param dstDir The `strings` directory in the core package. + * @param xlatLangs The set of languages that are configured for translation. + */ + writeJsFiles(context: BuildContext, dstDir: string /*, xlatLangs: Map*/): void { + writeLangJsFiles(context, dstDir, this.records /*, xlatLangs*/) + } +} + +function getSortedRecords(records: Map): StringRecord[] { + // Sort records by string key + return Array.from(records.values()).sort((a, b) => { + return a.key > b.key ? 1 : b.key > a.key ? -1 : 0 + }) } function checkInvisibleCharacters(s: string): void { @@ -119,6 +128,33 @@ function checkInvisibleCharacters(s: string): void { } } +function utf8SubscriptToHtml(key: StringKey, s: string): string { + if (key.includes('graph_yaxis_label')) { + // Chart.js doesn't support using HTML tags like subscripts or superscripts + // in axis labels. For now, we will convert subscript literals in axis labels + // to a simple number. (We could preserve the subscript literal, but it doesn't + // render all that well.) + s = s.replace(/₂/gi, '2') + s = s.replace(/₃/gi, '3') + s = s.replace(/₄/gi, '4') + s = s.replace(/₆/gi, '6') + return s + } + + // Unicode subscript literals render differently in some browsers (very low + // in Safari for example), so we will replace them with HTML `sub` tags + s = s.replace(/₂/gi, '2') + s = s.replace(/₃/gi, '3') + s = s.replace(/₄/gi, '4') + s = s.replace(/₆/gi, '6') + + // If the subscript tag is followed by a space, that space needs to be + // replaced with a non-breaking space, otherwise the whitespace will be lost + s = s.replace(/<\/sub> /gi, '
 ') + + return s +} + function htmlSubscriptAndSuperscriptToUtf8(s: string): string { // Subscripts have a straight mapping in Unicode (U+208x) s = s.replace(/(\d)<\/sub>/gi, (_match, p1) => String.fromCharCode(0x2080 + Number(p1))) @@ -195,3 +231,98 @@ export function genStringKey(prefix: string, s: string): string { key = key.replace(/
/g, '_') return `${prefix}__${key}` } + +/** + * Write a `.js` file containing translated strings for each supported language. + * + * These files are currently saved as plain JS (ES6) files. The only difference compared + * to JSON files is these JS files start with `export default`, so converting to JSON is + * as trivial as stripping those two words, if needed. + * + * @param context The build context. + * @param dstDir The `strings` directory in the core package. + * @param records The string records. + * //@param xlatLangs The set of languages that are configured for translation. + */ +function writeLangJsFiles( + context: BuildContext, + dstDir: string, + records: Map + // xlatLangs: Map +): void { + const xlatMap: XlatMap = new Map() + const sortedRecords = getSortedRecords(records) + + // const baseStringForKey = (key: StringKey) => { + // const record = records.get(key) + // if (!record) { + // throw new Error(`No base string found for key=${key}`) + // } + // return record.str + // } + + // Add base (e.g., English) strings that were gathered from the config files + const enStrings: StringMap = new Map() + for (const record of sortedRecords) { + const s = record.str + enStrings.set(record.key, utf8SubscriptToHtml(record.key, s)) + } + // TODO: Don't assume English, make the base language configurable + xlatMap.set('en', enStrings) + + // TODO: Enable support for translation files (for now, we only write base strings) + // const hasSecondary = + // existsSync(projectFilePath('localization', 'graph-descriptions')) && + // existsSync(projectFilePath('localization', 'input-descriptions')) + // for (const lang of xlatLangs.keys()) { + // const langStrings: StringMap = new Map() + + // let poMsgs: Map + // const primaryMsgs = readXlatPoFile('primary', lang) + // if (hasSecondary) { + // const graphMsgs = readXlatPoFile('graph-descriptions', lang) + // const inputMsgs = readXlatPoFile('input-descriptions', lang) + // poMsgs = new Map([...primaryMsgs, ...graphMsgs, ...inputMsgs]) + // } else { + // poMsgs = primaryMsgs + // } + + // const xlatStringForKey = (key: StringKey) => { + // return poMsgs.get(key) + // } + + // // Add the translation of each English string. + // for (const record of sortedRecords) { + // if (record.grouping !== 'primary') { + // // Only include secondary strings (graph and input descriptions) if they are + // // explicitly requested for this language; if not included, the English + // // descriptions will be used as a fallback + // if (xlatLangs.get(lang).includeSecondary !== true) { + // continue + // } + // } + + // const xlatStr = xlatStringForKey(record.key) + // if (xlatStr) { + // // Add the translated string + // const s = xlatStr + // langStrings.set(record.key, utf8SubscriptToHtml(record.key, s)) + // } else { + // // No translation for this string. We don't add the English string to + // // map as a fallback. Instead, we configure the i18n library to use + // // English strings as a fallback at runtime. + // // console.warn(`WARNING: No translated string for lang=${lang} id=${stringObj.id}`) + // } + // } + + // xlatMap.set(lang, langStrings) + // } + + // Write a JS file for each language for use in the core package + for (const lang of xlatMap.keys()) { + const stringsForLang = xlatMap.get(lang) + const stringsObj = Object.fromEntries(stringsForLang) + const json = JSON.stringify(stringsObj, null, 2) + context.writeStagedFile('strings', dstDir, `${lang}.js`, `export default ${json}`) + } +} From 9e7b47f29fea103e5d6fdd518b723b5b4654526e Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Fri, 9 Sep 2022 14:24:13 -0700 Subject: [PATCH 06/24] feat: add graph view code and flesh out example sir app --- examples/sir/config/graphs.csv | 4 +- examples/sir/packages/sir-app/index.html | 3 +- examples/sir/packages/sir-app/package.json | 1 + .../sir/packages/sir-app/src/graph-view.ts | 309 ++++++++++++++++++ examples/sir/packages/sir-app/src/index.css | 124 ++++++- examples/sir/packages/sir-app/src/index.js | 87 +++-- examples/sir/packages/sir-app/tsconfig.json | 29 ++ .../packages/sir-core/src/config/config.ts | 29 ++ examples/sir/packages/sir-core/src/index.js | 18 - examples/sir/packages/sir-core/src/index.ts | 4 + .../sir/packages/sir-core/src/model/inputs.ts | 81 +++++ .../sir/packages/sir-core/src/model/model.ts | 153 +++++++++ examples/sir/packages/sir-core/tsconfig.json | 16 + examples/sir/sde.config.js | 9 + packages/plugin-config-csv/src/processor.ts | 10 +- pnpm-lock.yaml | 2 + 16 files changed, 804 insertions(+), 75 deletions(-) create mode 100644 examples/sir/packages/sir-app/src/graph-view.ts create mode 100644 examples/sir/packages/sir-app/tsconfig.json create mode 100644 examples/sir/packages/sir-core/src/config/config.ts delete mode 100644 examples/sir/packages/sir-core/src/index.js create mode 100644 examples/sir/packages/sir-core/src/index.ts create mode 100644 examples/sir/packages/sir-core/src/model/inputs.ts create mode 100644 examples/sir/packages/sir-core/src/model/model.ts create mode 100644 examples/sir/packages/sir-core/tsconfig.json diff --git a/examples/sir/config/graphs.csv b/examples/sir/config/graphs.csv index f4a67ccd..5df6ca2b 100644 --- a/examples/sir/config/graphs.csv +++ b/examples/sir/config/graphs.csv @@ -1,3 +1,3 @@ id,side,parent menu,graph title,menu title,mini title,vensim graph,kind,modes,units,alternate,unused 1,unused 2,unused 3,x axis min,x axis max,x axis label,unused 4,unused 5,y axis min,y axis max,y axis soft max,y axis label,y axis format,unused 6,unused 7,plot 1 variable,plot 1 source,plot 1 style,plot 1 label,plot 1 color,plot 1 unused 1,plot 1 unused 2,plot 2 variable,plot 2 source,plot 2 style,plot 2 label,plot 2 color,plot 2 unused 1,plot 2 unused 2,plot 3 variable,plot 3 source,plot 3 style,plot 3 label,plot 3 color,plot 3 unused 1,plot 3 unused 2,plot 4 variable,plot 4 source,plot 4 style,plot 4 label,plot 4 color,plot 4 unused 1,plot 4 unused 2,plot 5 variable,plot 5 source,plot 5 style,plot 5 label,plot 5 color,plot 5 unused 1,plot 5 unused 2,plot 6 variable,plot 6 source,plot 6 style,plot 6 label,plot 6 color,plot 6 unused 1,plot 6 unused 2,plot 7 variable,plot 7 source,plot 7 style,plot 7 label,plot 7 color,plot 7 unused 1,plot 7 unused 2,plot 8 variable,plot 8 source,plot 8 style,plot 8 label,plot 8 color,plot 8 unused 1,plot 8 unused 2,plot 9 variable,plot 9 source,plot 9 style,plot 9 label,plot 9 color,plot 9 unused 1,plot 9 unused 2,plot 10 variable,plot 10 source,plot 10 style,plot 10 label,plot 10 color,plot 10 unused 1,plot 10 unused 2,plot 11 variable,plot 11 source,plot 11 style,plot 11 label,plot 11 color,plot 11 unused 1,plot 11 unused 2,plot 12 variable,plot 12 source,plot 12 style,plot 12 label,plot 12 color,plot 12 unused 1,plot 12 unused 2,plot 13 variable,plot 13 source,plot 13 style,plot 13 label,plot 13 color,plot 13 unused 1,plot 13 unused 2,plot 14 variable,plot 14 source,plot 14 style,plot 14 label,plot 14 color,plot 14 unused 1,plot 14 unused 2,plot 15 variable,plot 15 source,plot 15 style,plot 15 label,plot 15 color,plot 15 unused 1,plot 15 unused 2 -1,,Graphs,Infection and Recovery Rates,,,,line,,people/day,,,,,,,,,,,2000,,,,,,Infection Rate,,line,Infection Rate,blue,,,Recovery Rate,,line,Recovery Rate,red,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, -2,,Graphs,Population,,,,line,,people,,,,,,,,,,,12000,,,,,,Susceptible Population S,,line,Susceptible,blue,,,Infectious Population I,,line,Infectious,red,,,Recovered Population R,,line,Recovered,green,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, +1,,Graphs,Infection and Recovery Rates,,,,line,,,,,,,,,,,,,2000,,people/day,,,,Infection Rate,,line,Infection Rate,blue,,,Recovery Rate,,line,Recovery Rate,red,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, +2,,Graphs,Population,,,,line,,,,,,,,,,,,,12000,,people,,,,Susceptible Population S,,line,Susceptible,blue,,,Infectious Population I,,line,Infectious,red,,,Recovered Population R,,line,Recovered,green,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, diff --git a/examples/sir/packages/sir-app/index.html b/examples/sir/packages/sir-app/index.html index 2dc1ce72..acebedec 100644 --- a/examples/sir/packages/sir-app/index.html +++ b/examples/sir/packages/sir-app/index.html @@ -6,7 +6,6 @@ - @@ -19,7 +18,7 @@
-
HI
+
Inputs
diff --git a/examples/sir/packages/sir-app/package.json b/examples/sir/packages/sir-app/package.json index f549aeea..9b9762eb 100644 --- a/examples/sir/packages/sir-app/package.json +++ b/examples/sir/packages/sir-app/package.json @@ -18,6 +18,7 @@ "jquery": "^3.5.1" }, "devDependencies": { + "@types/chart.js": "^2.9.34", "vite": "^2.9.12" } } diff --git a/examples/sir/packages/sir-app/src/graph-view.ts b/examples/sir/packages/sir-app/src/graph-view.ts new file mode 100644 index 00000000..f05f406b --- /dev/null +++ b/examples/sir/packages/sir-app/src/graph-view.ts @@ -0,0 +1,309 @@ +import type { ChartConfiguration, ChartData, ChartDataSets } from 'chart.js' +import { Chart } from 'chart.js' + +import type { GraphDatasetSpec, GraphSpec, OutputVarId, Series, StringKey } from '@core' + +/** View model for a graph. */ +export interface GraphViewModel { + /** The spec that describes the graph datasets and visuals. */ + spec: GraphSpec + + /** + * Optional callback to customize graph line width. If defined, + * this will be called after layout events (e.g. after the browser + * window is resized.) + * + * @return The graph line width in pixels. + */ + getLineWidth?(): number + + /** + * Optional callback to customize graph scale label font size. + * If defined, this will be called after layout events (e.g. after + * the browser window is resized.) + * + * @return The graph scale label font size in pixels. + */ + getScaleLabelFontSize?(): number + + /** + * Optional callback to customize graph axis label font size. + * If defined, this will be called after layout events (e.g. after + * the browser window is resized.) + * + * @return The graph axis label font size in pixels. + */ + getAxisLabelFontSize?(): number + + /** + * Optional callback to filter the datasets that are displayed in the graph. + * If not defined, all datasets from the graph spec will be displayed. + * + * @return The subset of datasets to display. + */ + getDatasets?(): GraphDatasetSpec[] + + /** + * Return the series data for the given model output variable. + * + * @param varId The output variable ID associated with the data. + * @param sourceName The external data source name (e.g. "Ref"), or + * undefined to use the latest model output data. + */ + getSeriesForVar(varId: OutputVarId, sourceName?: string): Series | undefined + + /** + * Return the translated string for the given key. + * + * @param key The string key. + * @param values The optional map of values to substitute into the template string. + */ + getStringForKey(key: StringKey, values?: { [key: string]: string }): string + + /** + * Return a formatted string for the given y-axis tick value. + * + * @param value The number value. + */ + formatYAxisTickValue(value: number): string +} + +/** + * Options for graph view styling. + */ +export interface GraphViewOptions { + /** CSS-style font family string (can include comma-separated fallbacks). */ + fontFamily?: string + /** CSS-style font style. */ + fontStyle?: string + /** CSS-style hex color. */ + fontColor?: string +} + +/** + * Wraps a native chart element. + */ +export class GraphView { + private chart: Chart + + constructor(readonly canvas: HTMLCanvasElement, readonly viewModel: GraphViewModel, options: GraphViewOptions) { + this.chart = createChart(canvas, viewModel, options) + } + + /** + * Update the chart to reflect the latest data from the model. + * This should be called after the model has produced new outputs. + * + * @param animated Whether to animate the data when it is updated. + */ + updateData(animated = true) { + if (this.chart) { + // Update the chart data + updateLineChartJsData(this.viewModel, this.chart.data) + + // Refresh the chart view + this.chart.update(animated ? undefined : { duration: 0 }) + } + } + + /** + * Destroy the chart and any associated resources. + */ + destroy() { + this.chart?.destroy() + this.chart = undefined + } +} + +function createChart(canvas: HTMLCanvasElement, viewModel: GraphViewModel, options: GraphViewOptions): Chart { + // Create the chart data and config depending on the given style + const chartData = createLineChartJsData(viewModel.spec) + const chartJsConfig = lineChartJsConfig(viewModel, chartData) + updateLineChartJsData(viewModel, chartData) + + // Use built-in responsive resizing support. Note that for this to work + // correctly, the canvas parent must be a container with a fixed size + // (in `px` or `vw` units) and `position: relative`. For more information: + // https://www.chartjs.org/docs/latest/general/responsive.html + chartJsConfig.options.responsive = true + chartJsConfig.options.maintainAspectRatio = false + + // Disable the built-in title and legend + chartJsConfig.options.title = { display: false } + chartJsConfig.options.legend = { display: false } + + // Don't show points + chartJsConfig.options.elements = { + point: { + radius: 0 + } + } + + // Set the initial (translated) axis labels + const graphSpec = viewModel.spec + const xAxisLabel = stringForKey(viewModel, graphSpec.xAxisLabelKey) + const yAxisLabel = stringForKey(viewModel, graphSpec.yAxisLabelKey) + chartJsConfig.options.scales.xAxes[0].scaleLabel.labelString = xAxisLabel + chartJsConfig.options.scales.yAxes[0].scaleLabel.labelString = yAxisLabel + + // Apply the font options for labels and ticks + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function applyFontOptions(obj: any | undefined) { + if (obj) { + obj.fontFamily = options.fontFamily + obj.fontStyle = options.fontStyle + obj.fontColor = options.fontColor + } + } + applyFontOptions(chartJsConfig.options.scales.xAxes[0].scaleLabel) + applyFontOptions(chartJsConfig.options.scales.yAxes[0].scaleLabel) + applyFontOptions(chartJsConfig.options.scales.xAxes[0].ticks) + applyFontOptions(chartJsConfig.options.scales.yAxes[0].ticks) + + return new Chart(canvas, chartJsConfig) +} + +function stringForKey(viewModel: GraphViewModel, key?: StringKey): string | undefined { + if (key) { + return viewModel.getStringForKey(key) + } else { + return undefined + } +} + +function lineChartJsConfig(viewModel: GraphViewModel, data: ChartData): ChartConfiguration { + const spec = viewModel.spec + + const chartConfig: ChartConfiguration = { + type: 'line', + data, + options: { + scales: { + xAxes: [ + { + type: 'linear', + position: 'bottom', + scaleLabel: { + display: spec.xAxisLabelKey !== undefined, + padding: { + top: 0, + bottom: 5 + } + }, + ticks: { + maxTicksLimit: 6, + maxRotation: 0, + min: spec.xMin, + max: spec.xMax + } + } + ], + yAxes: [ + { + scaleLabel: { + display: true + }, + ticks: { + beginAtZero: true, + min: spec.yMin, + max: spec.yMax, + suggestedMax: spec.ySoftMax, + callback: value => { + return viewModel.formatYAxisTickValue(value as number) + } + }, + stacked: isStacked(spec) + } + ] + }, + tooltips: { + enabled: false // TODO: Make configurable + } + } + } + + return chartConfig +} + +function createLineChartJsData(spec: GraphSpec): ChartData { + const varCount = spec.datasets.length + const stacked = isStacked(spec) + const chartDatasets: ChartDataSets[] = [] + + for (let varIndex = 0; varIndex < varCount; varIndex++) { + const chartDataset: ChartDataSets = {} + + const color = spec.datasets[varIndex].color + const lineStyle = spec.datasets[varIndex].lineStyle + const lineStyleModifiers = spec.datasets[varIndex].lineStyleModifiers || [] + if (stacked && lineStyle === 'area') { + // This is an area section of a stacked chart; display it with fill style + // and disable the border (which would otherwise make the section appear + // larger than it should be, and would cause misalignment with the ref line). + chartDataset.fill = true + chartDataset.borderColor = 'rgba(0, 0, 0, 0)' + chartDataset.backgroundColor = color + } else if (lineStyle === 'scatter') { + // This is a scatter plot. We configure the chart type and dot color here, + // but the point radius will be configured in `applyScaleFactors`. + chartDataset.type = 'scatter' + chartDataset.fill = false + chartDataset.borderColor = 'rgba(0, 0, 0, 0)' + chartDataset.backgroundColor = color + } else { + // This is a line plot. Always specify a background color even if fill is + // disabled; this ensures that the color square is correct for tooltips. + chartDataset.backgroundColor = color + // This is a normal line plot; no fill + chartDataset.fill = false + if (lineStyle === 'none') { + // Make the line transparent (typically only used for confidence intervals) + chartDataset.borderColor = 'rgba(0, 0, 0, 0)' + } else { + // Use the specified color for the line + chartDataset.borderColor = color + chartDataset.borderCapStyle = 'round' + } + } + + chartDataset.pointHitRadius = 3 + chartDataset.pointHoverRadius = 0 + + chartDatasets.push(chartDataset) + } + + return { + datasets: chartDatasets + } +} + +function updateLineChartJsData(viewModel: GraphViewModel, chartData: ChartData): void { + function getSeries(varId: OutputVarId, sourceName?: string): Series | undefined { + const series = viewModel.getSeriesForVar(varId, sourceName) + if (!series) { + console.error(`ERROR: No data available for ${varId} (source=${sourceName || 'model'})`) + } + return series + } + + const visibleDatasetSpecs = viewModel.getDatasets?.() || viewModel.spec.datasets + const varCount = chartData.datasets.length + for (let varIndex = 0; varIndex < varCount; varIndex++) { + const specDataset = viewModel.spec.datasets[varIndex] + const varId = specDataset.varId + const sourceName = specDataset.externalSourceName + const series = getSeries(varId, sourceName) + if (series) { + chartData.datasets[varIndex].data = series.points + } + const visible = visibleDatasetSpecs.find(d => d.varId === varId && d.externalSourceName === sourceName) + chartData.datasets[varIndex].hidden = visible === undefined + } +} + +function isStacked(spec: GraphSpec): boolean { + // A graph that includes a plot with a line style of area is a stacked graph. + // Note that other plot line styles are ignored, except for the special case + // where a ref line is specified (with a line style other than 'area'). + return spec.kind === 'stacked-line' +} diff --git a/examples/sir/packages/sir-app/src/index.css b/examples/sir/packages/sir-app/src/index.css index 7513d508..2b48175f 100644 --- a/examples/sir/packages/sir-app/src/index.css +++ b/examples/sir/packages/sir-app/src/index.css @@ -1,3 +1,125 @@ +html, body { - background-color: blueviolet; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + box-sizing: border-box; + background-color: #fff; + font-family: Helvetica, sans-serif; + font-size: 1vw; +} + +p { + margin-top: 0; + margin-bottom: 0.7vw; +} + +/* + * Top panel: graphs + */ + +#graphs-container { + min-height: 50%; + padding: 0 1vw; +} + +#graph-selector { + margin-top: 1vw; +} + +/* + * This container is set up to allow for automatic responsive sizing + * by Chart.js. For this to work, we need the canvas element to have + * this parent container with `position: relative` and fixed dimensions + * using `vw` units. + */ +.graph-container { + position: relative; + width: 38vw; + height: 24vw; +} + +#top-graph-container { + margin-top: 1vw; +} + +/* + * Bottom panel: sliders and switches + */ + +#inputs-container { + display: inline-flex; + flex-direction: column; + padding: 0 1vw; + height: 100%; + overflow-x: hidden; + overflow-y: scroll; + background-color: #ddd; +} + +#inputs-title { + margin-top: 1vw; + font-weight: bold; + font-size: 1.5em; + color: #111; +} + +.input-title { + font-weight: bold; + font-size: 1.2em; + color: #111; + margin-top: 1vw; +} + +.input-desc { + font-style: italic; + color: #333; + margin-bottom: 2vw; +} + +.switch-checkbox { + margin-bottom: 0.6vw; +} + +.switch-label { + font-weight: bold; + font-size: 1.2em; + color: #111; + vertical-align: middle; + margin-left: 0.4vw; +} + +/* + * Customizations for bootstrap-slider + */ + +.slider.slider-horizontal { + width: 94%; + height: 1.5vw; + margin-left: 3%; + margin-top: 0.2vw; + margin-bottom: 0.2vw; +} + +.slider.slider-horizontal .slider-track { + height: 1vw; + top: 0.25vw; /* (slider-handle:height / 2) - (slider-track:height / 2) */ + margin-top: 0; +} + +.slider-rangeHighlight { + background: #8888aa; +} + +.slider-handle { + width: 1.5vw; + height: 1.5vw; + background: #000; +} + +.slider.slider-horizontal .slider-handle { + margin-left: -0.75vw; } diff --git a/examples/sir/packages/sir-app/src/index.js b/examples/sir/packages/sir-app/src/index.js index 4bbea08a..8ef334ff 100644 --- a/examples/sir/packages/sir-app/src/index.js +++ b/examples/sir/packages/sir-app/src/index.js @@ -1,13 +1,14 @@ import $ from 'jquery' import Slider from 'bootstrap-slider' import 'bootstrap-slider/dist/css/bootstrap-slider.css' +import './index.css' -import { createModel } from '@core' +import { config as coreConfig, createModel } from '@core' import enStrings from '@core-strings/en' -let coreConfig +import { GraphView } from './graph-view' + let model -let modelContext let graphView /** @@ -28,22 +29,28 @@ function addSliderItem(sliderInput) { const div = $(`
`).append([ $(`
${str(spec.labelKey)}
`), $(``), - $(`
${str(spec.descriptionKey)}
`) + $(`
${spec.descriptionKey ? str(spec.descriptionKey) : ''}
`) ]) $('#inputs-content').append(div) + const value = sliderInput.get() const slider = new Slider(`#${inputElemId}`, { - value: sliderInput.get(), + value, min: spec.minValue, max: spec.maxValue, step: spec.step, reversed: spec.reversed, - tooltip: 'hide' + tooltip: 'hide', + selection: 'none', + rangeHighlights: [{ start: spec.defaultValue, end: value }] }) // Update the model input when the slider is dragged or the track is clicked slider.on('change', change => { + const start = spec.defaultValue + const end = change.newValue + slider.setAttribute('rangeHighlights', [{ start, end }]) sliderInput.set(change.newValue) }) } @@ -75,35 +82,33 @@ function addSwitchItem(switchInput) { // This is a switch that controls whether the slider that follows it is active addCheckbox('The following slider will have an effect only when this is checked.') for (const sliderId of spec.slidersActiveWhenOn) { - const slider = modelContext.getInputForId(sliderId) + const slider = model.getInputForId(sliderId) addSliderItem(slider) } } else { // This is a detailed settings switch; when it's off, the sliders above it // are active and the sliders below are inactive (and vice versa) for (const sliderId of spec.slidersActiveWhenOff) { - const slider = modelContext.getInputForId(sliderId) + const slider = model.getInputForId(sliderId) addSliderItem(slider) } addCheckbox( 'When this is unchecked, only the slider above has an effect, and the ones below are inactive (and vice versa).' ) for (const sliderId of spec.slidersActiveWhenOn) { - const slider = modelContext.getInputForId(sliderId) + const slider = model.getInputForId(sliderId) addSliderItem(slider) } } } -function showInputs() {} - /** * Initialize the UI for the inputs menu and panel. */ function initInputsUI() { $('#inputs-content').empty() - for (const inputId of coreConfig.inputIds) { - const input = modelContext.getInputForId(inputId) + for (const inputId of coreConfig.inputs.keys()) { + const input = model.getInputForId(inputId) if (input.kind === 'slider') { addSliderItem(input) } else if (input.kind === 'switch') { @@ -116,37 +121,26 @@ function initInputsUI() { * GRAPHS */ -// function createGraphViewModel(graphSpec) { -// return { -// spec: graphSpec, -// style: 'normal', -// getLineWidth: () => window.innerWidth * (0.5 / 100), -// getScaleLabelFontSize: () => window.innerWidth * (1.2 / 100), -// getAxisLabelFontSize: () => window.innerWidth * (1.0 / 100), -// getSeriesForVar: (varId, sourceName) => { -// return modelContext.getSeriesForVar(varId, sourceName) -// }, -// getStringForKey: key => { -// // TODO: Inject values if string is templated -// return str(key) -// }, -// formatYAxisTickValue: value => { -// // TODO: Can use d3-format here and pass graphSpec.yFormat -// const stringValue = value.toFixed(1) -// if (graphSpec.kind === 'h-bar' && graphSpec.id !== '142') { -// // For bar charts that display percentages, format as a percent value -// return `${stringValue}%` -// } else { -// // For all other cases, return the string value without units -// return stringValue -// } -// }, -// formatYAxisTooltipValue: value => { -// // TODO: Can use d3-format here and pass '.2~f', for example -// return value.toFixed(2) -// } -// } -// } +function createGraphViewModel(graphSpec) { + return { + spec: graphSpec, + style: 'normal', + getLineWidth: () => window.innerWidth * (0.5 / 100), + getScaleLabelFontSize: () => window.innerWidth * (1.2 / 100), + getAxisLabelFontSize: () => window.innerWidth * (1.0 / 100), + getSeriesForVar: (varId, sourceName) => { + return model.getSeriesForVar(varId, sourceName) + }, + getStringForKey: key => { + // TODO: Inject values if string is templated + return str(key) + }, + formatYAxisTickValue: value => { + // TODO: Can use d3-format here and pass graphSpec.yFormat + return value.toFixed(1) + } + } +} function showGraph(graphSpec) { if (graphView) { @@ -205,7 +199,6 @@ async function initApp() { // Initialize the model asynchronously try { model = await createModel() - modelContext = model.addContext() } catch (e) { console.error(`ERROR: Failed to load model: ${e.message}`) return @@ -216,11 +209,11 @@ async function initApp() { initGraphsUI() // When the model outputs are updated, refresh the graph - modelContext.onOutputsChanged(() => { + model.onOutputsChanged = () => { if (graphView) { graphView.updateData() } - }) + } } // Initialize the app when this script is loaded diff --git a/examples/sir/packages/sir-app/tsconfig.json b/examples/sir/packages/sir-app/tsconfig.json new file mode 100644 index 00000000..c08f31a8 --- /dev/null +++ b/examples/sir/packages/sir-app/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // We specify the top-level directory as the root, since TypeScript requires + // all referenced files to live underneath the root directory + "rootDir": "..", + // Make `paths` in the tsconfig files relative to the `app` directory + "baseUrl": ".", + // Configure path aliases + "paths": { + // The following lines enable path aliases within the app + "@core": ["../sir-core/src"], + "@core-strings": ["../sir-core/strings"] + }, + // XXX: The following two lines work around a TS/VSCode issue where this config + // file shows an error ("Cannot write file appcfg.js because it would overwrite + // input file") + "outDir": "/dev/shm", + "noEmit": true, + "declaration": false, + "target": "es6", + "module": "esnext", + "moduleResolution": "node", + "allowJs": true, + "noImplicitAny": false, + "noUnusedLocals": false, + "noUnusedParameters": false, + "types": ["vite/client"] + } +} diff --git a/examples/sir/packages/sir-core/src/config/config.ts b/examples/sir/packages/sir-core/src/config/config.ts new file mode 100644 index 00000000..395c5528 --- /dev/null +++ b/examples/sir/packages/sir-core/src/config/config.ts @@ -0,0 +1,29 @@ +import { graphSpecs, inputSpecs } from './generated/config-specs' +import type { GraphId, GraphSpec, InputId, InputSpec } from './generated/spec-types' + +/** + * Exposes all the configuration that can be used to build a user + * interface around the included model. + */ +export class Config { + /** + * @param inputs The available input specs; these are in the order expected by the model. + * @param graphs The available graph specs. + */ + constructor( + public readonly inputs: ReadonlyMap, + public readonly graphs: ReadonlyMap + ) {} +} + +function createConfig(): Config { + // Convert the arrays from `config-specs.ts` to maps + const inputs: Map = new Map(inputSpecs.map(spec => [spec.id, spec])) + const graphs: Map = new Map(graphSpecs.map(spec => [spec.id, spec])) + return new Config(inputs, graphs) +} + +/** + * The default configuration for the included model instance. + */ +export const config: Config = createConfig() diff --git a/examples/sir/packages/sir-core/src/index.js b/examples/sir/packages/sir-core/src/index.js deleted file mode 100644 index cde06e23..00000000 --- a/examples/sir/packages/sir-core/src/index.js +++ /dev/null @@ -1,18 +0,0 @@ -import { ModelScheduler } from '@sdeverywhere/runtime' -import { spawnAsyncModelRunner } from '@sdeverywhere/runtime-async' -import modelWorkerJs from './model/generated/worker.js?raw' - -export class Model { - constructor() {} -} - -export async function createModel() { - // Initialize the wasm model asynchronously. We inline the worker code in the - // rolled-up bundle, so that we don't have to fetch a separate `worker.js` file. - const runner = await spawnAsyncModelRunner({ source: modelWorkerJs }) - - // Create the model scheduler - const scheduler = new ModelScheduler(runner, inputs, outputs) - - // TODO -} diff --git a/examples/sir/packages/sir-core/src/index.ts b/examples/sir/packages/sir-core/src/index.ts new file mode 100644 index 00000000..18260d8d --- /dev/null +++ b/examples/sir/packages/sir-core/src/index.ts @@ -0,0 +1,4 @@ +export type { Series, Point } from '@sdeverywhere/runtime' +export * from './config/generated/spec-types' +export * from './config/config' +export * from './model/model' diff --git a/examples/sir/packages/sir-core/src/model/inputs.ts b/examples/sir/packages/sir-core/src/model/inputs.ts new file mode 100644 index 00000000..2751172a --- /dev/null +++ b/examples/sir/packages/sir-core/src/model/inputs.ts @@ -0,0 +1,81 @@ +import type { InputCallbacks, InputValue, InputVarId } from '@sdeverywhere/runtime' +import type { InputSpec, SliderSpec, SwitchSpec } from '../config/generated/spec-types' + +/** + * Represents a slider (range) input to the model. + */ +export interface SliderInput extends InputValue { + kind: 'slider' + /** The spec that describes how the slider can be displayed in a user interface. */ + spec: SliderSpec +} + +/** + * Represents a switch (on/off) input to the model. + */ +export interface SwitchInput extends InputValue { + kind: 'switch' + /** The spec that describes how the switch can be displayed in a user interface. */ + spec: SwitchSpec +} + +/** + * Represents an input to the model. + */ +export type Input = SliderInput | SwitchInput + +/** + * Create an `Input` instance that can be used by the `Model` class. + * When the input value is changed, it will cause the scheduler to + * automatically run the model and produce new outputs. + * + * @param spec The spec for the slider or switch input. + */ +export function createModelInput(spec: InputSpec): Input { + let currentValue = spec.defaultValue + + // The `onSet` callback is initially undefined but will be installed by `ModelScheduler` + const callbacks: InputCallbacks = {} + + const get = () => { + return currentValue + } + + const set = (newValue: number) => { + if (newValue !== currentValue) { + currentValue = newValue + callbacks.onSet?.() + } + } + + const reset = () => { + set(spec.defaultValue) + } + + switch (spec.kind) { + case 'slider': + return { kind: 'slider', varId: spec.varId, spec, get, set, reset, callbacks } + case 'switch': + return { kind: 'switch', varId: spec.varId, spec, get, set, reset, callbacks } + default: + throw new Error(`Unhandled spec kind`) + } +} + +/** + * Create an `InputValue` that is only used to hold a simple value (no spec, no callbacks). + * @hidden + */ +export function createSimpleInputValue(varId: InputVarId, defaultValue = 0): InputValue { + let currentValue = defaultValue + const get = () => { + return currentValue + } + const set = (newValue: number) => { + currentValue = newValue + } + const reset = () => { + set(defaultValue) + } + return { varId, get, set, reset, callbacks: {} } +} diff --git a/examples/sir/packages/sir-core/src/model/model.ts b/examples/sir/packages/sir-core/src/model/model.ts new file mode 100644 index 00000000..e7934a08 --- /dev/null +++ b/examples/sir/packages/sir-core/src/model/model.ts @@ -0,0 +1,153 @@ +import type { InputValue, InputVarId, ModelRunner, OutputVarId, Series } from '@sdeverywhere/runtime' +import { ModelScheduler, Outputs } from '@sdeverywhere/runtime' +import { spawnAsyncModelRunner } from '@sdeverywhere/runtime-async' +import type { InputId } from '../config/generated/spec-types' +import { config } from '../config/config' +import { endTime, outputVarIds, startTime } from './generated/model-spec' +import { createModelInput, createSimpleInputValue, Input } from './inputs' +import modelWorkerJs from './generated/worker.js?raw' + +/** + * High-level interface to the runnable model. + * + * When one or more input values are changed, this class will schedule a model + * run to be completed as soon as possible. When the model run has completed, + * the output data will be saved (accessible using the `getSeriesForVar` function), + * and `onOutputsChanged` is called to notify that new data is available. + */ +export class Model { + /** The model scheduler. */ + private readonly scheduler: ModelScheduler + + /** + * The structure into which the model outputs will be stored. + */ + private outputs: Outputs + + /** + * Called when the outputs have been updated after a model run. + */ + public onOutputsChanged?: () => void + + constructor( + runner: ModelRunner, + private readonly inputs: Map, + initialOutputs: Outputs, + private readonly refData: ReadonlyMap + ) { + const inputsArray = Array.from(inputs.values()) + this.outputs = initialOutputs + this.scheduler = new ModelScheduler(runner, inputsArray, initialOutputs) + this.scheduler.onOutputsChanged = outputs => { + this.outputs = outputs + this.onOutputsChanged?.() + } + } + + /** + * Return the model input for the given input ID, or undefined if there is + * no input for that ID. + */ + public getInputForId(inputId: InputId): Input | undefined { + return this.inputs.get(inputId) + } + + /** + * Return the series data for the given model output variable. + * + * @param varId The ID of the output variable associated with the data. + * @param sourceName The external data source name (e.g. "Ref"), or + * undefined to use the latest model output data. + */ + public getSeriesForVar(varId: OutputVarId, sourceName?: string): Series | undefined { + if (sourceName === undefined) { + // Return the latest model output data + console.log(this.outputs) + return this.outputs.getSeriesForVar(varId) + } else if (sourceName === 'Ref') { + // Return the saved reference data + return this.refData.get(varId) + } else { + // TODO: Add support for static/external data + // // Return the static external data + // const dataset = staticData[sourceName] + // if (dataset) { + // const points = dataset[varId] + // if (points) { + // return new Series(varId, points) + // } + // } + return undefined + } + } +} + +/** + * Create a `Model` instance. + * + * This is an asynchronous operation because it performs an initial + * model run to capture the reference/baseline data. + */ +export async function createModel(): Promise { + // Initialize the wasm model asynchronously. We inline the worker code in the + // rolled-up bundle, so that we don't have to fetch a separate `worker.js` file. + const runner = await spawnAsyncModelRunner({ source: modelWorkerJs }) + + // Run the model with inputs set to their default values + const defaultInputs: InputValue[] = [] + for (const inputSpec of config.inputs.values()) { + defaultInputs.push(createSimpleInputValue(inputSpec.varId, inputSpec.defaultValue)) + } + const defaultOutputs = createOutputs() + const initialOutputs = await runner.runModel(defaultInputs, defaultOutputs) + + // Capture data from the reference run for the given variables; note that we + // must copy the series data, since the `Outputs` instance can be reused by + // the runner and otherwise the data might be overwritten + const refData: Map = new Map() + const refVarIds = getRefOutputs() + for (const refVarId of refVarIds) { + const refSeries = initialOutputs.getSeriesForVar(refVarId) + if (refSeries) { + refData.set(refVarId, refSeries.copy()) + } else { + console.error(`ERROR: No reference data available for ${refVarId}`) + } + } + + // Create the `Model` instance + const initialInputs = createInputs() + return new Model(runner, initialInputs, initialOutputs, refData) +} + +function createInputs(): Map { + const orderedInputs: Map = new Map() + for (const inputSpec of config.inputs.values()) { + const input = createModelInput(inputSpec) + orderedInputs.set(input.spec.id, input) + } + return orderedInputs +} + +function createOutputs(): Outputs { + return new Outputs(outputVarIds, startTime, endTime) +} + +/** + * Return the set of output variables that are needed for reference data. This + * includes output variables that appear with a "Ref" dataset in one or more + * graph specs. + */ +function getRefOutputs(): Set { + // Gather the set of output variables that appear with a "Ref" dataset + // in one or more graph specs + const refVarIds: Set = new Set() + for (const graphSpec of config.graphs.values()) { + for (const dataset of graphSpec.datasets) { + if (dataset.externalSourceName === 'Ref') { + refVarIds.add(dataset.varId) + } + } + } + return refVarIds +} diff --git a/examples/sir/packages/sir-core/tsconfig.json b/examples/sir/packages/sir-core/tsconfig.json new file mode 100644 index 00000000..358b1a35 --- /dev/null +++ b/examples/sir/packages/sir-core/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "allowJs": true, + "module": "es6", + "target": "es6", + "moduleResolution": "node", + "isolatedModules": true, + "importsNotUsedAsValues": "error", + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "types": ["vite/client"] + }, + "include": ["src/**/*"] +} diff --git a/examples/sir/sde.config.js b/examples/sir/sde.config.js index 5d609ded..9145132f 100644 --- a/examples/sir/sde.config.js +++ b/examples/sir/sde.config.js @@ -19,6 +19,15 @@ export async function config() { // Specify the Vensim model to read modelFiles: ['model/sir.mdl'], + // The following files will be hashed to determine whether the model needs + // to be rebuilt when watch mode is active + modelInputPaths: ['model/**'], + + // The following files will cause the model to be rebuilt when watch mode is + // is active. Note that these are globs so we use forward slashes regardless + // of platform. + watchPaths: ['config/**', 'model/**'], + // Read csv files from `config` directory modelSpec: configProcessor({ config: configDir, diff --git a/packages/plugin-config-csv/src/processor.ts b/packages/plugin-config-csv/src/processor.ts index a051e5bd..23fe4284 100644 --- a/packages/plugin-config-csv/src/processor.ts +++ b/packages/plugin-config-csv/src/processor.ts @@ -96,17 +96,17 @@ async function processModelConfig(buildContext: BuildContext, options: ConfigOpt // Write the generated files context.log('info', 'Generating files...') - if (outModelSpecsDir) { - context.log('verbose', ' Writing model specs') - writeModelSpec(context, outModelSpecsDir) - } - const configSpecs = generateConfigSpecs(context) if (outConfigSpecsDir) { context.log('verbose', ' Writing config specs') writeConfigSpecs(context, configSpecs, outConfigSpecsDir) } + if (outModelSpecsDir) { + context.log('verbose', ' Writing model specs') + writeModelSpec(context, outModelSpecsDir) + } + if (outStringsDir) { context.log('verbose', ' Writing strings') context.writeStringsFiles(outStringsDir) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c74b091f..f8b9b66c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,7 @@ importers: examples/sir/packages/sir-app: specifiers: + '@types/chart.js': ^2.9.34 bootstrap-slider: 10.6.2 chart.js: ^2.9.4 jquery: ^3.5.1 @@ -126,6 +127,7 @@ importers: chart.js: 2.9.4 jquery: 3.6.1 devDependencies: + '@types/chart.js': 2.9.37 vite: 2.9.12 examples/sir/packages/sir-core: From 2b1754c24700a7998125d8c0ae14639706e6134f Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Fri, 9 Sep 2022 15:09:17 -0700 Subject: [PATCH 07/24] fix: use SAVEPER of 1 since that's all we support at the moment --- examples/sir/model/sir.mdl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/sir/model/sir.mdl b/examples/sir/model/sir.mdl index bb4fb9b9..8aa031cd 100755 --- a/examples/sir/model/sir.mdl +++ b/examples/sir/model/sir.mdl @@ -113,7 +113,7 @@ INITIAL TIME = 0 ~ The initial time for the simulation. | -SAVEPER = 2 +SAVEPER = 1 ~ Day ~ The frequency with which output is stored. | From 4cd22bdbcd66d789445a4b8290d67888f8238367 Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Fri, 9 Sep 2022 15:10:09 -0700 Subject: [PATCH 08/24] fix: tweak inputs and sde config --- examples/sir/.gitignore | 3 +++ examples/sir/config/inputs.csv | 4 ++-- examples/sir/model/sir.check.yaml | 3 ++- examples/sir/sde.config.js | 4 ++-- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/examples/sir/.gitignore b/examples/sir/.gitignore index 3c0f0280..f5c47214 100644 --- a/examples/sir/.gitignore +++ b/examples/sir/.gitignore @@ -1 +1,4 @@ sde-prep +*.vdf +*.vdfx +*.3vmfx diff --git a/examples/sir/config/inputs.csv b/examples/sir/config/inputs.csv index 71d1110f..84abdcd1 100644 --- a/examples/sir/config/inputs.csv +++ b/examples/sir/config/inputs.csv @@ -1,4 +1,4 @@ id,input type,viewid,varname,label,view level,group name,slider min,slider max,slider/switch default,slider step,units,format,reversed,range 2 start,range 3 start,range 4 start,range 5 start,range 1 label,range 2 label,range 3 label,range 4 label,range 5 label,enabled value,disabled value,controlled input ids,listing label,description -1,slider,v1,Initial Contact Rate,Initial Contact Rate,,Inputs,0,5,1,0.1,per day,.1f,,,,,,,,,,,,,,, -2,slider,v1,Infectivity i,Infectivity,,Inputs,-2,2,0.1,0.1,probability,.1f,,,,,,,,,,,,,,, +1,slider,v1,Initial Contact Rate,Initial Contact Rate,,Inputs,0,5,2.5,0.1,per day,.1f,,,,,,,,,,,,,,, +2,slider,v1,Infectivity i,Infectivity,,Inputs,-2,2,0.25,0.05,probability,.2f,,,,,,,,,,,,,,, 3,slider,v1,Average Duration of Illness d,Average Duration of Illness,,Inputs,0,10,2,1,days,,,,,,,,,,,,,,,, diff --git a/examples/sir/model/sir.check.yaml b/examples/sir/model/sir.check.yaml index aa45fdef..71b6df62 100644 --- a/examples/sir/model/sir.check.yaml +++ b/examples/sir/model/sir.check.yaml @@ -2,7 +2,7 @@ - describe: Population Variables tests: - - it: should be non-negative all input scenarios + - it: should be between 0 and 10000 for all input scenarios scenarios: - preset: matrix datasets: @@ -11,3 +11,4 @@ - name: Susceptible Population S predicates: - gte: 0 + - lte: 10000 diff --git a/examples/sir/sde.config.js b/examples/sir/sde.config.js index 9145132f..20694945 100644 --- a/examples/sir/sde.config.js +++ b/examples/sir/sde.config.js @@ -21,12 +21,12 @@ export async function config() { // The following files will be hashed to determine whether the model needs // to be rebuilt when watch mode is active - modelInputPaths: ['model/**'], + modelInputPaths: ['model/*.mdl'], // The following files will cause the model to be rebuilt when watch mode is // is active. Note that these are globs so we use forward slashes regardless // of platform. - watchPaths: ['config/**', 'model/**'], + watchPaths: ['config/**', 'model/*.mdl'], // Read csv files from `config` directory modelSpec: configProcessor({ From 27021f352fd52b12ea8d9c790fc44111b0dbadc4 Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Sat, 17 Sep 2022 17:54:31 -0700 Subject: [PATCH 09/24] fix: improve sample app layout and style --- examples/sir/packages/sir-app/index.html | 2 +- examples/sir/packages/sir-app/src/index.css | 51 +++++++++++---------- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/examples/sir/packages/sir-app/index.html b/examples/sir/packages/sir-app/index.html index acebedec..6521984b 100644 --- a/examples/sir/packages/sir-app/index.html +++ b/examples/sir/packages/sir-app/index.html @@ -18,7 +18,7 @@
-
Inputs
+
diff --git a/examples/sir/packages/sir-app/src/index.css b/examples/sir/packages/sir-app/src/index.css index 2b48175f..7e6e7d18 100644 --- a/examples/sir/packages/sir-app/src/index.css +++ b/examples/sir/packages/sir-app/src/index.css @@ -3,18 +3,19 @@ body { display: flex; flex-direction: column; width: 100%; + max-width: 700px; height: 100%; - margin: 0; + margin: 0 auto; overflow: hidden; box-sizing: border-box; background-color: #fff; font-family: Helvetica, sans-serif; - font-size: 1vw; + font-size: 13px; } p { margin-top: 0; - margin-bottom: 0.7vw; + margin-bottom: 10px; } /* @@ -22,28 +23,28 @@ p { */ #graphs-container { - min-height: 50%; - padding: 0 1vw; + padding: 0 10px; } #graph-selector { - margin-top: 1vw; + margin-top: 10px; } /* * This container is set up to allow for automatic responsive sizing * by Chart.js. For this to work, we need the canvas element to have * this parent container with `position: relative` and fixed dimensions - * using `vw` units. + * using `px` or `vw`/`vh` units. */ .graph-container { position: relative; - width: 38vw; - height: 24vw; + width: 100%; + height: 40vh; } #top-graph-container { - margin-top: 1vw; + margin-top: 16px; + margin-bottom: 10px; } /* @@ -53,7 +54,7 @@ p { #inputs-container { display: inline-flex; flex-direction: column; - padding: 0 1vw; + padding: 0 10px; height: 100%; overflow-x: hidden; overflow-y: scroll; @@ -61,7 +62,7 @@ p { } #inputs-title { - margin-top: 1vw; + margin-top: 10px; font-weight: bold; font-size: 1.5em; color: #111; @@ -71,17 +72,17 @@ p { font-weight: bold; font-size: 1.2em; color: #111; - margin-top: 1vw; + margin-top: 12px; } .input-desc { font-style: italic; color: #333; - margin-bottom: 2vw; + margin-bottom: 24px; } .switch-checkbox { - margin-bottom: 0.6vw; + margin-bottom: 8px; } .switch-label { @@ -89,7 +90,7 @@ p { font-size: 1.2em; color: #111; vertical-align: middle; - margin-left: 0.4vw; + margin-left: 6px; } /* @@ -98,28 +99,28 @@ p { .slider.slider-horizontal { width: 94%; - height: 1.5vw; + height: 16px; margin-left: 3%; - margin-top: 0.2vw; - margin-bottom: 0.2vw; + margin-top: 4px; + margin-bottom: 4px; } .slider.slider-horizontal .slider-track { - height: 1vw; - top: 0.25vw; /* (slider-handle:height / 2) - (slider-track:height / 2) */ + height: 8px; + top: 4px; /* (slider-handle:height / 2) - (slider-track:height / 2) */ margin-top: 0; } .slider-rangeHighlight { - background: #8888aa; + background: #5588ff; } .slider-handle { - width: 1.5vw; - height: 1.5vw; + width: 16px; + height: 16px; background: #000; } .slider.slider-horizontal .slider-handle { - margin-left: -0.75vw; + margin-left: -8px; } From ab6f6c2172dcbd0be37b3c5aace28e2b0c02661e Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Sat, 17 Sep 2022 18:09:03 -0700 Subject: [PATCH 10/24] fix: remove obsolete sir config files and extra mdl copy --- models/sir/model/config/app.csv | 2 - models/sir/model/config/colors.csv | 6 - models/sir/model/config/graphs.csv | 3 - models/sir/model/config/sliders.csv | 4 - models/sir/model/config/views.csv | 2 - models/sir/model/logo.png | Bin 2508 -> 0 bytes models/sir/model/sir.mdl | 217 ---------------------------- 7 files changed, 234 deletions(-) delete mode 100644 models/sir/model/config/app.csv delete mode 100644 models/sir/model/config/colors.csv delete mode 100644 models/sir/model/config/graphs.csv delete mode 100644 models/sir/model/config/sliders.csv delete mode 100644 models/sir/model/config/views.csv delete mode 100644 models/sir/model/logo.png delete mode 100755 models/sir/model/sir.mdl diff --git a/models/sir/model/config/app.csv b/models/sir/model/config/app.csv deleted file mode 100644 index 59384753..00000000 --- a/models/sir/model/config/app.csv +++ /dev/null @@ -1,2 +0,0 @@ -title,version,initialView,trackSliders,initialTime,startTime,endTime,externalDatfiles,chartDatfiles,logo,helpUrl -SIR,1.0.0,Models > SIR Model,TRUE,0,0,200,,,logo.png,http://www.mhhe.com/business/opsci/sterman/models.mhtml \ No newline at end of file diff --git a/models/sir/model/config/colors.csv b/models/sir/model/config/colors.csv deleted file mode 100644 index 4a8eba38..00000000 --- a/models/sir/model/config/colors.csv +++ /dev/null @@ -1,6 +0,0 @@ -colorId,colorName,hexCode -1,blue,#0072b2 -2,red,#d33700 -3,green,#53bb37 -4,gray,#a7a9ac -5,black,#000000 \ No newline at end of file diff --git a/models/sir/model/config/graphs.csv b/models/sir/model/config/graphs.csv deleted file mode 100644 index fa6f0c12..00000000 --- a/models/sir/model/config/graphs.csv +++ /dev/null @@ -1,3 +0,0 @@ -title,xAxisMin,xAxisMax,xAxisUnits,xAxisFormat,yAxisMin,yAxisMax,yAxisUnits,yAxisFormat,plot1Variable,plot1Label,plot1Style,plot1Color,plot1Dataset,plot2Variable,plot2Label,plot2Style,plot2Color,plot2Dataset,plot3Variable,plot3Label,plot3Style,plot3Color,plot3Dataset,plot4Variable,plot4Label,plot4Style,plot4Color,plot4Dataset,plot5Variable,plot5Label,plot5Style,plot5Color,plot5Dataset,plot6Variable,plot6Label,plot6Style,plot6Color,plot6Dataset,plot7Variable,plot7Label,plot7Style,plot7Color,plot7Dataset,plot8Variable,plot8Label,plot8Style,plot8Color,plot8Dataset,description -Infection and Recovery Rates,0,200,,,0,2000,people/day,0,Infection Rate,Infection Rate,line,1,,Recovery Rate,Recovery Rate,line,2,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, -Population,0,200,,,0,12000,people,0a,Susceptible Population S,Susceptible,line,1,,Infectious Population I,Infectious,line,2,,Recovered Population R,Recovered,line,3,,,,,,,,,,,,,,,,,,,,,,,,,,, \ No newline at end of file diff --git a/models/sir/model/config/sliders.csv b/models/sir/model/config/sliders.csv deleted file mode 100644 index b0227efd..00000000 --- a/models/sir/model/config/sliders.csv +++ /dev/null @@ -1,4 +0,0 @@ -viewTitle,varName,label,sliderMin,sliderMax,sliderDefault,sliderStep,units,format,description -Models > SIR Model,Initial Contact Rate,Initial Contact Rate,0,5,1,0.1,per day,'0.0', -Models > SIR Model,Infectivity i,Infectivity,-2,2,0.1,0.25,probability,'0.00', -Models > SIR Model,Average Duration of Illness d,Average Duration of Illness,0,10,2,1,days,0a, \ No newline at end of file diff --git a/models/sir/model/config/views.csv b/models/sir/model/config/views.csv deleted file mode 100644 index bf888611..00000000 --- a/models/sir/model/config/views.csv +++ /dev/null @@ -1,2 +0,0 @@ -title,leftGraph,rightGraph,helpUrl,description -Models > SIR Model,Infection and Recovery Rates,Population,, \ No newline at end of file diff --git a/models/sir/model/logo.png b/models/sir/model/logo.png deleted file mode 100644 index d7fe0f63c33cc1bf9c2006423ace3e8d7911594f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2508 zcmaJ@c|4T)AD=?G$&q6#VdA~pJ&*$_0I}R0e@S zYOXFsPuYruC>jp{*)ur70N9m93k5s@8Y3q15r7AQ ztlM%200Sz3@ieU3`(YBD!yg1o@^%@QVe-06Z z+d}>k)0bSblP%R!dq7B$NMt zD2w$qnlJDK{?+?GiTRWmE&%rg_#7dRE*o6P_SIBeoC6P_2{=3ohZC{ZMUO)q0f&Ex z!v#Aytac3pCe!Fl_Uhed44I5`W%C6zHXU#!+CpS3Fea0Mb0i?KRv3&W0_BKAA{{Xp zYikS^iNQEnVbEAhC&U_-$e{~a09&wzW&De^`X+YO4lJ%LGZEl14+9J*9)|_~TsDsR zZ7s-e@_oTFzO4oIO)Oj{2EIDn{~Gq%mCQk_+ppG@9ln|$V9OlOlUe)0<4;2%&<4CK z(Vi0hvfGy&;peCs@Tx2Ua>Mij6!N{>c8?V3Yu-+?0T;@s!mc!J?o~daPIJLCZ%ExhicZ|*P$A@s+n82jC ztK>}ABDJhuMWwzYxmTPTya#-j(6FhfljWsZXPCX9R-lm7PFf)ZozgyK(7jWsbzbfS zzgY6p{iX|HsM;o}-#?7+9biR#>SmZ*+?0zJ5Q7EX~v{ntMw;AoKByxR_6thyoPd%0+pgZ#grc9_nzwo zLF>s88(jy}_v{(zw>L{_0>y>qMJX(Z%5=zN-kp|B8!DgVe2&5&&c1e<9^t9 zk5P8x^T;p+06~`|`;~{RN@xd%YYrVL-K?{NM*#NT@nC zBUPi_-k!X-wDW>zsG(R+;{yzGTU>N9d`@ChU1Wanv~jST$`EWt)F!v{oOxJ=e%$c3 zxJyWc`PiV1XsG9>+-^sRww#7f%4YqGmm^8H`=HD|z5Q7QS|Ub4x^nu)ryb|I)*va>{0x9px*Jx>m`?CWmbFtV~VeIe*y`q9`(%M zBt%l$E~Lv}+mz|`mnwbGqerOvPPsQhW^Az(rCaGfVmL1>ApJg}60P0g%` z(|N#zXjy_JxQoTRQ=6p{IlGcc(I{)&Mptv9LdVGzHFiB&`aX{nKln`7YjMf#LWxbP zxKsK;9j|9eT^hM2)-&|#J-MG`SbVS6E#3CW!}7TJmiZGSC1;m^GkLJ(k7MkaSoOih zY|Z(%OPTx(Kd(&E0LJ?9C7t~mx4b)7?412}>r@R(Ze`*xmNyk8vm5n~_kRDPhB$hp zOpGI$_P16DUjRuzKuMbZP~#k@mgOYp{Nw=)e{@60=^Imy)Te3I)4$%Sh!Zwn8bObn z!xCJIK~XmYwj2&93DL#mY;>|1You!Drxsf6`vuY77VAdsIJKm5R~HnYZo>yHUnx~_XSh8fi)beEN;wuwpJ%)=o~_pl^s z-}6^#6|WJ>zvnAOD@(MKG%lR$W%|bw@>CxzP2Qia!cD)lOQ<7!2X?Ux!u@Qn9vmY& zc@c^s+z4?>)aHlHgkI(_MoK?Ur${`CRn{kywP>-KlT8sNkMk7}F6Y-l6| Date: Sat, 17 Sep 2022 18:15:20 -0700 Subject: [PATCH 11/24] refactor: rename plugin-config-csv -> plugin-config --- examples/sir/package.json | 2 +- examples/sir/sde.config.js | 2 +- packages/{plugin-config-csv => plugin-config}/.eslintignore | 0 packages/{plugin-config-csv => plugin-config}/.eslintrc.cjs | 0 packages/{plugin-config-csv => plugin-config}/.gitignore | 0 .../{plugin-config-csv => plugin-config}/.prettierignore | 0 packages/{plugin-config-csv => plugin-config}/LICENSE | 0 packages/{plugin-config-csv => plugin-config}/README.md | 2 +- packages/{plugin-config-csv => plugin-config}/package.json | 4 ++-- .../src/__tests__/config1/colors.csv | 0 .../src/__tests__/config1/graphs.csv | 0 .../src/__tests__/config1/inputs.csv | 0 .../src/__tests__/config1/model.csv | 0 .../src/__tests__/config1/outputs.csv | 0 .../src/__tests__/config1/strings.csv | 0 .../{plugin-config-csv => plugin-config}/src/context.ts | 0 .../src/gen-config-specs.ts | 2 +- .../{plugin-config-csv => plugin-config}/src/gen-graphs.ts | 0 .../{plugin-config-csv => plugin-config}/src/gen-inputs.ts | 0 .../src/gen-model-spec.ts | 2 +- packages/{plugin-config-csv => plugin-config}/src/index.ts | 0 .../src/processor.spec.ts | 6 +++--- .../{plugin-config-csv => plugin-config}/src/processor.ts | 0 .../{plugin-config-csv => plugin-config}/src/read-config.ts | 0 .../{plugin-config-csv => plugin-config}/src/spec-types.ts | 0 .../{plugin-config-csv => plugin-config}/src/strings.ts | 0 .../{plugin-config-csv => plugin-config}/src/var-names.ts | 0 .../{plugin-config-csv => plugin-config}/tsconfig-base.json | 0 .../tsconfig-build.json | 0 .../{plugin-config-csv => plugin-config}/tsconfig-test.json | 0 packages/{plugin-config-csv => plugin-config}/tsconfig.json | 0 .../{plugin-config-csv => plugin-config}/tsup.config.ts | 0 pnpm-lock.yaml | 6 +++--- 33 files changed, 13 insertions(+), 13 deletions(-) rename packages/{plugin-config-csv => plugin-config}/.eslintignore (100%) rename packages/{plugin-config-csv => plugin-config}/.eslintrc.cjs (100%) rename packages/{plugin-config-csv => plugin-config}/.gitignore (100%) rename packages/{plugin-config-csv => plugin-config}/.prettierignore (100%) rename packages/{plugin-config-csv => plugin-config}/LICENSE (100%) rename packages/{plugin-config-csv => plugin-config}/README.md (88%) rename packages/{plugin-config-csv => plugin-config}/package.json (94%) rename packages/{plugin-config-csv => plugin-config}/src/__tests__/config1/colors.csv (100%) rename packages/{plugin-config-csv => plugin-config}/src/__tests__/config1/graphs.csv (100%) rename packages/{plugin-config-csv => plugin-config}/src/__tests__/config1/inputs.csv (100%) rename packages/{plugin-config-csv => plugin-config}/src/__tests__/config1/model.csv (100%) rename packages/{plugin-config-csv => plugin-config}/src/__tests__/config1/outputs.csv (100%) rename packages/{plugin-config-csv => plugin-config}/src/__tests__/config1/strings.csv (100%) rename packages/{plugin-config-csv => plugin-config}/src/context.ts (100%) rename packages/{plugin-config-csv => plugin-config}/src/gen-config-specs.ts (98%) rename packages/{plugin-config-csv => plugin-config}/src/gen-graphs.ts (100%) rename packages/{plugin-config-csv => plugin-config}/src/gen-inputs.ts (100%) rename packages/{plugin-config-csv => plugin-config}/src/gen-model-spec.ts (97%) rename packages/{plugin-config-csv => plugin-config}/src/index.ts (100%) rename packages/{plugin-config-csv => plugin-config}/src/processor.spec.ts (96%) rename packages/{plugin-config-csv => plugin-config}/src/processor.ts (100%) rename packages/{plugin-config-csv => plugin-config}/src/read-config.ts (100%) rename packages/{plugin-config-csv => plugin-config}/src/spec-types.ts (100%) rename packages/{plugin-config-csv => plugin-config}/src/strings.ts (100%) rename packages/{plugin-config-csv => plugin-config}/src/var-names.ts (100%) rename packages/{plugin-config-csv => plugin-config}/tsconfig-base.json (100%) rename packages/{plugin-config-csv => plugin-config}/tsconfig-build.json (100%) rename packages/{plugin-config-csv => plugin-config}/tsconfig-test.json (100%) rename packages/{plugin-config-csv => plugin-config}/tsconfig.json (100%) rename packages/{plugin-config-csv => plugin-config}/tsup.config.ts (100%) diff --git a/examples/sir/package.json b/examples/sir/package.json index d7ff5e08..0c55dbd8 100644 --- a/examples/sir/package.json +++ b/examples/sir/package.json @@ -10,7 +10,7 @@ "dependencies": { "@sdeverywhere/cli": "^0.7.0", "@sdeverywhere/plugin-check": "^0.1.0", - "@sdeverywhere/plugin-config-csv": "^0.1.0", + "@sdeverywhere/plugin-config": "^0.1.0", "@sdeverywhere/plugin-vite": "^0.1.1", "@sdeverywhere/plugin-wasm": "^0.1.0", "@sdeverywhere/plugin-worker": "^0.1.0" diff --git a/examples/sir/sde.config.js b/examples/sir/sde.config.js index 20694945..f1ca0bd6 100644 --- a/examples/sir/sde.config.js +++ b/examples/sir/sde.config.js @@ -2,7 +2,7 @@ import { dirname, join as joinPath } from 'path' import { fileURLToPath } from 'url' import { checkPlugin } from '@sdeverywhere/plugin-check' -import { configProcessor } from '@sdeverywhere/plugin-config-csv' +import { configProcessor } from '@sdeverywhere/plugin-config' import { vitePlugin } from '@sdeverywhere/plugin-vite' import { wasmPlugin } from '@sdeverywhere/plugin-wasm' import { workerPlugin } from '@sdeverywhere/plugin-worker' diff --git a/packages/plugin-config-csv/.eslintignore b/packages/plugin-config/.eslintignore similarity index 100% rename from packages/plugin-config-csv/.eslintignore rename to packages/plugin-config/.eslintignore diff --git a/packages/plugin-config-csv/.eslintrc.cjs b/packages/plugin-config/.eslintrc.cjs similarity index 100% rename from packages/plugin-config-csv/.eslintrc.cjs rename to packages/plugin-config/.eslintrc.cjs diff --git a/packages/plugin-config-csv/.gitignore b/packages/plugin-config/.gitignore similarity index 100% rename from packages/plugin-config-csv/.gitignore rename to packages/plugin-config/.gitignore diff --git a/packages/plugin-config-csv/.prettierignore b/packages/plugin-config/.prettierignore similarity index 100% rename from packages/plugin-config-csv/.prettierignore rename to packages/plugin-config/.prettierignore diff --git a/packages/plugin-config-csv/LICENSE b/packages/plugin-config/LICENSE similarity index 100% rename from packages/plugin-config-csv/LICENSE rename to packages/plugin-config/LICENSE diff --git a/packages/plugin-config-csv/README.md b/packages/plugin-config/README.md similarity index 88% rename from packages/plugin-config-csv/README.md rename to packages/plugin-config/README.md index d78faedd..a9eeaa83 100644 --- a/packages/plugin-config-csv/README.md +++ b/packages/plugin-config/README.md @@ -1,4 +1,4 @@ -# @sdeverywhere/plugin-config-csv +# @sdeverywhere/plugin-config This package provides a plugin that reads CSV files used to configure a library or app around an SDEverywhere-generated system dynamics model. diff --git a/packages/plugin-config-csv/package.json b/packages/plugin-config/package.json similarity index 94% rename from packages/plugin-config-csv/package.json rename to packages/plugin-config/package.json index d83bfa69..b5482eb9 100644 --- a/packages/plugin-config-csv/package.json +++ b/packages/plugin-config/package.json @@ -1,5 +1,5 @@ { - "name": "@sdeverywhere/plugin-config-csv", + "name": "@sdeverywhere/plugin-config", "version": "0.1.0", "files": [ "dist/**" @@ -50,7 +50,7 @@ "repository": { "type": "git", "url": "https://github.com/climateinteractive/SDEverywhere.git", - "directory": "packages/plugin-config-csv" + "directory": "packages/plugin-config" }, "bugs": { "url": "https://github.com/climateinteractive/SDEverywhere/issues" diff --git a/packages/plugin-config-csv/src/__tests__/config1/colors.csv b/packages/plugin-config/src/__tests__/config1/colors.csv similarity index 100% rename from packages/plugin-config-csv/src/__tests__/config1/colors.csv rename to packages/plugin-config/src/__tests__/config1/colors.csv diff --git a/packages/plugin-config-csv/src/__tests__/config1/graphs.csv b/packages/plugin-config/src/__tests__/config1/graphs.csv similarity index 100% rename from packages/plugin-config-csv/src/__tests__/config1/graphs.csv rename to packages/plugin-config/src/__tests__/config1/graphs.csv diff --git a/packages/plugin-config-csv/src/__tests__/config1/inputs.csv b/packages/plugin-config/src/__tests__/config1/inputs.csv similarity index 100% rename from packages/plugin-config-csv/src/__tests__/config1/inputs.csv rename to packages/plugin-config/src/__tests__/config1/inputs.csv diff --git a/packages/plugin-config-csv/src/__tests__/config1/model.csv b/packages/plugin-config/src/__tests__/config1/model.csv similarity index 100% rename from packages/plugin-config-csv/src/__tests__/config1/model.csv rename to packages/plugin-config/src/__tests__/config1/model.csv diff --git a/packages/plugin-config-csv/src/__tests__/config1/outputs.csv b/packages/plugin-config/src/__tests__/config1/outputs.csv similarity index 100% rename from packages/plugin-config-csv/src/__tests__/config1/outputs.csv rename to packages/plugin-config/src/__tests__/config1/outputs.csv diff --git a/packages/plugin-config-csv/src/__tests__/config1/strings.csv b/packages/plugin-config/src/__tests__/config1/strings.csv similarity index 100% rename from packages/plugin-config-csv/src/__tests__/config1/strings.csv rename to packages/plugin-config/src/__tests__/config1/strings.csv diff --git a/packages/plugin-config-csv/src/context.ts b/packages/plugin-config/src/context.ts similarity index 100% rename from packages/plugin-config-csv/src/context.ts rename to packages/plugin-config/src/context.ts diff --git a/packages/plugin-config-csv/src/gen-config-specs.ts b/packages/plugin-config/src/gen-config-specs.ts similarity index 98% rename from packages/plugin-config-csv/src/gen-config-specs.ts rename to packages/plugin-config/src/gen-config-specs.ts index ba9a0e37..ac64e487 100644 --- a/packages/plugin-config-csv/src/gen-config-specs.ts +++ b/packages/plugin-config/src/gen-config-specs.ts @@ -51,7 +51,7 @@ export function writeConfigSpecs(context: ConfigContext, config: ConfigSpecs, ds tsContent += s + '\n' } - emit('// This file is generated by `@sdeverywhere/plugin-config-csv`; do not edit manually!') + emit('// This file is generated by `@sdeverywhere/plugin-config`; do not edit manually!') emit('') emit(`import type { GraphSpec, InputSpec } from './spec-types'`) diff --git a/packages/plugin-config-csv/src/gen-graphs.ts b/packages/plugin-config/src/gen-graphs.ts similarity index 100% rename from packages/plugin-config-csv/src/gen-graphs.ts rename to packages/plugin-config/src/gen-graphs.ts diff --git a/packages/plugin-config-csv/src/gen-inputs.ts b/packages/plugin-config/src/gen-inputs.ts similarity index 100% rename from packages/plugin-config-csv/src/gen-inputs.ts rename to packages/plugin-config/src/gen-inputs.ts diff --git a/packages/plugin-config-csv/src/gen-model-spec.ts b/packages/plugin-config/src/gen-model-spec.ts similarity index 97% rename from packages/plugin-config-csv/src/gen-model-spec.ts rename to packages/plugin-config/src/gen-model-spec.ts index 608afd56..62983263 100644 --- a/packages/plugin-config-csv/src/gen-model-spec.ts +++ b/packages/plugin-config/src/gen-model-spec.ts @@ -17,7 +17,7 @@ export function writeModelSpec(context: ConfigContext, dstDir: string): void { tsContent += s + '\n' } - emit('// This file is generated by `@sdeverywhere/plugin-config-csv`; do not edit manually!') + emit('// This file is generated by `@sdeverywhere/plugin-config`; do not edit manually!') emit(`export const startTime = ${context.modelStartTime}`) emit(`export const endTime = ${context.modelEndTime}`) emit(`export const inputVarIds: string[] = ${JSON.stringify(inputVarIds, null, 2)}`) diff --git a/packages/plugin-config-csv/src/index.ts b/packages/plugin-config/src/index.ts similarity index 100% rename from packages/plugin-config-csv/src/index.ts rename to packages/plugin-config/src/index.ts diff --git a/packages/plugin-config-csv/src/processor.spec.ts b/packages/plugin-config/src/processor.spec.ts similarity index 96% rename from packages/plugin-config-csv/src/processor.spec.ts rename to packages/plugin-config/src/processor.spec.ts index 65260d1c..d58d5bb5 100644 --- a/packages/plugin-config-csv/src/processor.spec.ts +++ b/packages/plugin-config/src/processor.spec.ts @@ -22,7 +22,7 @@ interface TestEnv { } async function prepareForBuild(optionsFunc: (corePkgDir: string) => ConfigOptions): Promise { - const baseTmpDir = await temp.mkdir('sde-plugin-config-csv') + const baseTmpDir = await temp.mkdir('sde-plugin-config') // console.log(baseTmpDir) const projDir = joinPath(baseTmpDir, 'proj') await mkdir(projDir) @@ -51,7 +51,7 @@ async function prepareForBuild(optionsFunc: (corePkgDir: string) => ConfigOption } const modelSpec1 = `\ -// This file is generated by \`@sdeverywhere/plugin-config-csv\`; do not edit manually! +// This file is generated by \`@sdeverywhere/plugin-config\`; do not edit manually! export const startTime = 0 export const endTime = 200 export const inputVarIds: string[] = [] @@ -59,7 +59,7 @@ export const outputVarIds: string[] = [] ` const configSpecs1 = `\ -// This file is generated by \`@sdeverywhere/plugin-config-csv\`; do not edit manually! +// This file is generated by \`@sdeverywhere/plugin-config\`; do not edit manually! import type { GraphSpec, InputSpec } from './spec-types' diff --git a/packages/plugin-config-csv/src/processor.ts b/packages/plugin-config/src/processor.ts similarity index 100% rename from packages/plugin-config-csv/src/processor.ts rename to packages/plugin-config/src/processor.ts diff --git a/packages/plugin-config-csv/src/read-config.ts b/packages/plugin-config/src/read-config.ts similarity index 100% rename from packages/plugin-config-csv/src/read-config.ts rename to packages/plugin-config/src/read-config.ts diff --git a/packages/plugin-config-csv/src/spec-types.ts b/packages/plugin-config/src/spec-types.ts similarity index 100% rename from packages/plugin-config-csv/src/spec-types.ts rename to packages/plugin-config/src/spec-types.ts diff --git a/packages/plugin-config-csv/src/strings.ts b/packages/plugin-config/src/strings.ts similarity index 100% rename from packages/plugin-config-csv/src/strings.ts rename to packages/plugin-config/src/strings.ts diff --git a/packages/plugin-config-csv/src/var-names.ts b/packages/plugin-config/src/var-names.ts similarity index 100% rename from packages/plugin-config-csv/src/var-names.ts rename to packages/plugin-config/src/var-names.ts diff --git a/packages/plugin-config-csv/tsconfig-base.json b/packages/plugin-config/tsconfig-base.json similarity index 100% rename from packages/plugin-config-csv/tsconfig-base.json rename to packages/plugin-config/tsconfig-base.json diff --git a/packages/plugin-config-csv/tsconfig-build.json b/packages/plugin-config/tsconfig-build.json similarity index 100% rename from packages/plugin-config-csv/tsconfig-build.json rename to packages/plugin-config/tsconfig-build.json diff --git a/packages/plugin-config-csv/tsconfig-test.json b/packages/plugin-config/tsconfig-test.json similarity index 100% rename from packages/plugin-config-csv/tsconfig-test.json rename to packages/plugin-config/tsconfig-test.json diff --git a/packages/plugin-config-csv/tsconfig.json b/packages/plugin-config/tsconfig.json similarity index 100% rename from packages/plugin-config-csv/tsconfig.json rename to packages/plugin-config/tsconfig.json diff --git a/packages/plugin-config-csv/tsup.config.ts b/packages/plugin-config/tsup.config.ts similarity index 100% rename from packages/plugin-config-csv/tsup.config.ts rename to packages/plugin-config/tsup.config.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8b9b66c..f8d28630 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,14 +103,14 @@ importers: specifiers: '@sdeverywhere/cli': ^0.7.0 '@sdeverywhere/plugin-check': ^0.1.0 - '@sdeverywhere/plugin-config-csv': ^0.1.0 + '@sdeverywhere/plugin-config': ^0.1.0 '@sdeverywhere/plugin-vite': ^0.1.1 '@sdeverywhere/plugin-wasm': ^0.1.0 '@sdeverywhere/plugin-worker': ^0.1.0 dependencies: '@sdeverywhere/cli': link:../../packages/cli '@sdeverywhere/plugin-check': link:../../packages/plugin-check - '@sdeverywhere/plugin-config-csv': link:../../packages/plugin-config-csv + '@sdeverywhere/plugin-config': link:../../packages/plugin-config '@sdeverywhere/plugin-vite': link:../../packages/plugin-vite '@sdeverywhere/plugin-wasm': link:../../packages/plugin-wasm '@sdeverywhere/plugin-worker': link:../../packages/plugin-worker @@ -283,7 +283,7 @@ importers: devDependencies: '@types/node': 16.11.40 - packages/plugin-config-csv: + packages/plugin-config: specifiers: '@sdeverywhere/build': ^0.1.1 '@types/byline': ^4.2.33 From 13156200bf223cff419848ff6ed0e4678cffeb1a Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Sat, 17 Sep 2022 22:10:55 -0700 Subject: [PATCH 12/24] fix: include the check plugin before vite plugin (the previous order caused a build failure that needs investigation) --- examples/sir/sde.config.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/sir/sde.config.js b/examples/sir/sde.config.js index f1ca0bd6..2445e086 100644 --- a/examples/sir/sde.config.js +++ b/examples/sir/sde.config.js @@ -43,6 +43,9 @@ export async function config() { outputPaths: [corePath('src', 'model', 'generated', 'worker.js')] }), + // Run model check + checkPlugin(), + // Build or serve the model explorer app vitePlugin({ name: `${baseName}-app`, @@ -52,10 +55,7 @@ export async function config() { config: { configFile: appPath('vite.config.js') } - }), - - // Run model check - checkPlugin() + }) ] } } From 128cb64ba14b78b08a9c70548aa21fb3e3c4abf1 Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Sat, 17 Sep 2022 22:33:52 -0700 Subject: [PATCH 13/24] fix: show input value in sample app --- examples/sir/config/inputs.csv | 2 +- examples/sir/packages/sir-app/index.html | 1 - examples/sir/packages/sir-app/src/index.css | 22 +++++++++++++----- examples/sir/packages/sir-app/src/index.js | 25 ++++++++++++++++++++- 4 files changed, 42 insertions(+), 8 deletions(-) diff --git a/examples/sir/config/inputs.csv b/examples/sir/config/inputs.csv index 84abdcd1..3298b3fc 100644 --- a/examples/sir/config/inputs.csv +++ b/examples/sir/config/inputs.csv @@ -1,4 +1,4 @@ id,input type,viewid,varname,label,view level,group name,slider min,slider max,slider/switch default,slider step,units,format,reversed,range 2 start,range 3 start,range 4 start,range 5 start,range 1 label,range 2 label,range 3 label,range 4 label,range 5 label,enabled value,disabled value,controlled input ids,listing label,description 1,slider,v1,Initial Contact Rate,Initial Contact Rate,,Inputs,0,5,2.5,0.1,per day,.1f,,,,,,,,,,,,,,, -2,slider,v1,Infectivity i,Infectivity,,Inputs,-2,2,0.25,0.05,probability,.2f,,,,,,,,,,,,,,, +2,slider,v1,Infectivity i,Infectivity,,Inputs,-2,2,0.25,0.05,(probability),.2f,,,,,,,,,,,,,,, 3,slider,v1,Average Duration of Illness d,Average Duration of Illness,,Inputs,0,10,2,1,days,,,,,,,,,,,,,,,, diff --git a/examples/sir/packages/sir-app/index.html b/examples/sir/packages/sir-app/index.html index 6521984b..7196c703 100644 --- a/examples/sir/packages/sir-app/index.html +++ b/examples/sir/packages/sir-app/index.html @@ -18,7 +18,6 @@
-
diff --git a/examples/sir/packages/sir-app/src/index.css b/examples/sir/packages/sir-app/src/index.css index 7e6e7d18..69f6b26b 100644 --- a/examples/sir/packages/sir-app/src/index.css +++ b/examples/sir/packages/sir-app/src/index.css @@ -61,18 +61,30 @@ p { background-color: #ddd; } -#inputs-title { - margin-top: 10px; +.input-title-row { + display: flex; + flex-direction: row; + align-items: baseline; + margin-top: 12px; +} + +.input-title { + flex: 1; font-weight: bold; - font-size: 1.5em; + font-size: 1.2em; color: #111; } -.input-title { +.input-value { font-weight: bold; font-size: 1.2em; color: #111; - margin-top: 12px; +} + +.input-units { + margin-left: 5px; + font-size: 1em; + color: #111; } .input-desc { diff --git a/examples/sir/packages/sir-app/src/index.js b/examples/sir/packages/sir-app/src/index.js index 8ef334ff..173661c9 100644 --- a/examples/sir/packages/sir-app/src/index.js +++ b/examples/sir/packages/sir-app/src/index.js @@ -26,8 +26,15 @@ function addSliderItem(sliderInput) { const spec = sliderInput.spec const inputElemId = `input-${spec.id}` - const div = $(`
`).append([ + const inputValue = $(`
`) + const titleRow = $(`
`).append([ $(`
${str(spec.labelKey)}
`), + inputValue, + $(`
${str(spec.unitsKey)}
`) + ]) + + const div = $(`
`).append([ + titleRow, $(``), $(`
${spec.descriptionKey ? str(spec.descriptionKey) : ''}
`) ]) @@ -46,11 +53,27 @@ function addSliderItem(sliderInput) { rangeHighlights: [{ start: spec.defaultValue, end: value }] }) + // Show the initial value and update the value when the slider is changed + const updateValueElement = v => { + // TODO: Use d3-format + let s + if (spec.format === '.2f') { + s = v.toFixed(2) + } else if (spec.format === '.1f') { + s = v.toFixed(1) + } else { + s = v.toString() + } + inputValue.text(s.toString()) + } + updateValueElement(value) + // Update the model input when the slider is dragged or the track is clicked slider.on('change', change => { const start = spec.defaultValue const end = change.newValue slider.setAttribute('rangeHighlights', [{ start, end }]) + updateValueElement(change.newValue) sliderInput.set(change.newValue) }) } From 912098c257980c50e4b0f22f76ad86ec00aa5bbb Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Sat, 17 Sep 2022 22:34:14 -0700 Subject: [PATCH 14/24] fix: remove debug logging --- examples/sir/packages/sir-core/src/model/model.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/sir/packages/sir-core/src/model/model.ts b/examples/sir/packages/sir-core/src/model/model.ts index e7934a08..66af1e9f 100644 --- a/examples/sir/packages/sir-core/src/model/model.ts +++ b/examples/sir/packages/sir-core/src/model/model.ts @@ -62,7 +62,6 @@ export class Model { public getSeriesForVar(varId: OutputVarId, sourceName?: string): Series | undefined { if (sourceName === undefined) { // Return the latest model output data - console.log(this.outputs) return this.outputs.getSeriesForVar(varId) } else if (sourceName === 'Ref') { // Return the saved reference data From 2db417d6c33328d5c4ecab13c5c9e1cd3a4b568d Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Sat, 17 Sep 2022 22:42:34 -0700 Subject: [PATCH 15/24] test: update test to account for change that allows modelFiles array to be empty --- packages/build/tests/build/build-prod.spec.ts | 109 ++++++++++-------- 1 file changed, 63 insertions(+), 46 deletions(-) diff --git a/packages/build/tests/build/build-prod.spec.ts b/packages/build/tests/build/build-prod.spec.ts index 9b01ce58..a6003278 100644 --- a/packages/build/tests/build/build-prod.spec.ts +++ b/packages/build/tests/build/build-prod.spec.ts @@ -17,66 +17,83 @@ const modelSpec: ModelSpec = { datFiles: [] } +const plugin = (num: number, calls: string[]) => { + const record = (f: string) => { + calls.push(`plugin ${num}: ${f}`) + } + const p: Plugin = { + init: async () => { + record('init') + }, + preGenerate: async () => { + record('preGenerate') + }, + preProcessMdl: async () => { + record('preProcessMdl') + }, + postProcessMdl: async (_, mdlContent) => { + record('postProcessMdl') + return mdlContent + }, + preGenerateC: async () => { + record('preGenerateC') + }, + postGenerateC: async (_, cContent) => { + record('postGenerateC') + return cContent + }, + postGenerate: async () => { + record('postGenerate') + return true + }, + postBuild: async () => { + record('postBuild') + return true + }, + watch: async () => { + record('watch') + } + } + return p +} + describe('build in production mode', () => { - it('should fail if model files array is empty', async () => { + it('should skip certain callbacks if model files array is empty', async () => { + const calls: string[] = [] + const userConfig: UserConfig = { rootDir: resolvePath(__dirname, '..'), prepDir: resolvePath(__dirname, 'sde-prep'), modelFiles: [], - modelSpec: async () => modelSpec + modelSpec: async () => { + calls.push('modelSpec') + return modelSpec + }, + plugins: [plugin(1, calls), plugin(2, calls)] } const result = await build('production', buildOptions(userConfig)) - if (result.isOk()) { - throw new Error('Expected error result but got: ' + result.value) + if (result.isErr()) { + throw new Error('Expected ok result but got: ' + result.error.message) } - expect(result.error.message).toBe('No model input files specified') + expect(result.value.exitCode).toBe(0) + expect(calls).toEqual([ + 'plugin 1: init', + 'plugin 2: init', + 'modelSpec', + 'plugin 1: preGenerate', + 'plugin 2: preGenerate', + 'plugin 1: postGenerate', + 'plugin 2: postGenerate', + 'plugin 1: postBuild', + 'plugin 2: postBuild' + ]) }) it('should call plugin functions in the expected order', async () => { const calls: string[] = [] - const plugin = (num: number) => { - const record = (f: string) => { - calls.push(`plugin ${num}: ${f}`) - } - const p: Plugin = { - init: async () => { - record('init') - }, - preGenerate: async () => { - record('preGenerate') - }, - preProcessMdl: async () => { - record('preProcessMdl') - }, - postProcessMdl: async (_, mdlContent) => { - record('postProcessMdl') - return mdlContent - }, - preGenerateC: async () => { - record('preGenerateC') - }, - postGenerateC: async (_, cContent) => { - record('postGenerateC') - return cContent - }, - postGenerate: async () => { - record('postGenerate') - return true - }, - postBuild: async () => { - record('postBuild') - return true - }, - watch: async () => { - record('watch') - } - } - return p - } - const userConfig: UserConfig = { rootDir: resolvePath(__dirname, '..'), prepDir: resolvePath(__dirname, 'sde-prep'), @@ -85,7 +102,7 @@ describe('build in production mode', () => { calls.push('modelSpec') return modelSpec }, - plugins: [plugin(1), plugin(2)] + plugins: [plugin(1, calls), plugin(2, calls)] } const result = await build('production', buildOptions(userConfig)) From c442cd3c7e49e2ac00662ba11ad7aa40c4afa428 Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Sat, 17 Sep 2022 22:46:49 -0700 Subject: [PATCH 16/24] test: update test to check for var ids in generated model spec file --- packages/plugin-config/src/processor.spec.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/plugin-config/src/processor.spec.ts b/packages/plugin-config/src/processor.spec.ts index d58d5bb5..447f7bf1 100644 --- a/packages/plugin-config/src/processor.spec.ts +++ b/packages/plugin-config/src/processor.spec.ts @@ -23,7 +23,6 @@ interface TestEnv { async function prepareForBuild(optionsFunc: (corePkgDir: string) => ConfigOptions): Promise { const baseTmpDir = await temp.mkdir('sde-plugin-config') - // console.log(baseTmpDir) const projDir = joinPath(baseTmpDir, 'proj') await mkdir(projDir) const corePkgDir = joinPath(projDir, 'core-package') @@ -54,8 +53,14 @@ const modelSpec1 = `\ // This file is generated by \`@sdeverywhere/plugin-config\`; do not edit manually! export const startTime = 0 export const endTime = 200 -export const inputVarIds: string[] = [] -export const outputVarIds: string[] = [] +export const inputVarIds: string[] = [ + "_input_a", + "_input_b", + "_input_c" +] +export const outputVarIds: string[] = [ + "_var_1" +] ` const configSpecs1 = `\ From f1f0e29232367a4142fd5b1be04e89bc20b22f9f Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Sat, 17 Sep 2022 23:00:49 -0700 Subject: [PATCH 17/24] build: include subset of sir example packages in workspace for now --- pnpm-lock.yaml | 8 -------- pnpm-workspace.yaml | 4 +++- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8d28630..88bd0bc6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,14 +52,6 @@ importers: '@sdeverywhere/plugin-worker': link:../../packages/plugin-worker sirv-cli: 2.0.2 - examples/hello-world/sde-prep/template-report: - specifiers: - '@sdeverywhere/check-core': ^0.1.0 - '@sdeverywhere/check-ui-shell': ^0.1.0 - dependencies: - '@sdeverywhere/check-core': link:../../../../packages/check-core - '@sdeverywhere/check-ui-shell': link:../../../../packages/check-ui-shell - examples/sample-check-app: specifiers: '@sdeverywhere/check-core': workspace:* diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2c99b837..395ccd76 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,6 @@ packages: - packages/* - - examples/** + - examples/* + # TODO: This next line can be removed once we remove sir/packages + - examples/sir/packages/* - tests From cfb536f7b17b1d3260f32c409de0f1d5d6f023da Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Sun, 18 Sep 2022 10:51:58 -0700 Subject: [PATCH 18/24] fix: add simple number formatting function --- examples/sir/packages/sir-app/src/index.js | 31 +++++++++++++--------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/examples/sir/packages/sir-app/src/index.js b/examples/sir/packages/sir-app/src/index.js index 173661c9..78de2297 100644 --- a/examples/sir/packages/sir-app/src/index.js +++ b/examples/sir/packages/sir-app/src/index.js @@ -18,6 +18,23 @@ function str(key) { return enStrings[key] } +/** + * Return a formatted string representation of the given number. + */ +function format(num, formatString) { + // TODO: You could use d3-format or another similar formatting library + // here. For now, this is set up to handle a small subset of formats + // used in the example config files. + switch (formatString) { + case '.1f': + return num.toFixed(1) + case '.2f': + return num.toFixed(2) + default: + return num.toString() + } +} + /* * INPUTS */ @@ -55,16 +72,7 @@ function addSliderItem(sliderInput) { // Show the initial value and update the value when the slider is changed const updateValueElement = v => { - // TODO: Use d3-format - let s - if (spec.format === '.2f') { - s = v.toFixed(2) - } else if (spec.format === '.1f') { - s = v.toFixed(1) - } else { - s = v.toString() - } - inputValue.text(s.toString()) + inputValue.text(format(v, spec.format)) } updateValueElement(value) @@ -159,8 +167,7 @@ function createGraphViewModel(graphSpec) { return str(key) }, formatYAxisTickValue: value => { - // TODO: Can use d3-format here and pass graphSpec.yFormat - return value.toFixed(1) + return format(value, graphSpec.yFormat) } } } From 243c0c9ffa6741fe0dfd88c0dde1f2a0f667d69f Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Sun, 18 Sep 2022 11:13:34 -0700 Subject: [PATCH 19/24] fix: show simple legend for graph --- examples/sir/packages/sir-app/index.html | 7 +++++-- examples/sir/packages/sir-app/src/index.css | 23 ++++++++++++++++++++- examples/sir/packages/sir-app/src/index.js | 12 +++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/examples/sir/packages/sir-app/index.html b/examples/sir/packages/sir-app/index.html index 7196c703..03febc06 100644 --- a/examples/sir/packages/sir-app/index.html +++ b/examples/sir/packages/sir-app/index.html @@ -12,8 +12,11 @@
-
- +
+
+ +
+
diff --git a/examples/sir/packages/sir-app/src/index.css b/examples/sir/packages/sir-app/src/index.css index 69f6b26b..3e1ca426 100644 --- a/examples/sir/packages/sir-app/src/index.css +++ b/examples/sir/packages/sir-app/src/index.css @@ -30,13 +30,20 @@ p { margin-top: 10px; } +.graph-outer-container { + display: flex; + flex-direction: column; + margin-top: 10px; + margin-bottom: 20px; +} + /* * This container is set up to allow for automatic responsive sizing * by Chart.js. For this to work, we need the canvas element to have * this parent container with `position: relative` and fixed dimensions * using `px` or `vw`/`vh` units. */ -.graph-container { +.graph-inner-container { position: relative; width: 100%; height: 40vh; @@ -47,6 +54,20 @@ p { margin-bottom: 10px; } +.graph-legend { + display: flex; + flex-direction: row; + justify-content: center; + width: 100%; + margin-top: 2px; +} + +.graph-legend-item { + padding: 4px 8px 3px 8px; + margin: 1px 3px; + color: #fff; +} + /* * Bottom panel: sliders and switches */ diff --git a/examples/sir/packages/sir-app/src/index.js b/examples/sir/packages/sir-app/src/index.js index 78de2297..678d6c77 100644 --- a/examples/sir/packages/sir-app/src/index.js +++ b/examples/sir/packages/sir-app/src/index.js @@ -178,6 +178,7 @@ function showGraph(graphSpec) { graphView.destroy() } + // Create a new GraphView that targets the canvas element const canvas = $('#top-graph-canvas')[0] const viewModel = createGraphViewModel(graphSpec) const options = { @@ -189,6 +190,17 @@ function showGraph(graphSpec) { const xAxisLabel = graphSpec.xAxisLabelKey ? str(graphSpec.xAxisLabelKey) : undefined const yAxisLabel = graphSpec.yAxisLabelKey ? str(graphSpec.yAxisLabelKey) : undefined graphView = new GraphView(canvas, viewModel, options, tooltipsEnabled, xAxisLabel, yAxisLabel) + + // Show the legend items for the graph + const legendContainer = $('#top-graph-legend') + legendContainer.empty() + for (const itemSpec of graphSpec.legendItems) { + console.log(itemSpec) + const attrs = `class="graph-legend-item" style="background-color: ${itemSpec.color}"` + const label = str(itemSpec.labelKey) + const itemElem = $(`
${label}
`) + legendContainer.append(itemElem) + } } function addGraphItem(graphSpec) { From 275d2a27da8b4bde2204429ac9bcbd64b67611e0 Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Sun, 18 Sep 2022 11:14:14 -0700 Subject: [PATCH 20/24] fix: remove debug logging --- examples/sir/packages/sir-app/src/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/sir/packages/sir-app/src/index.js b/examples/sir/packages/sir-app/src/index.js index 678d6c77..a37170e2 100644 --- a/examples/sir/packages/sir-app/src/index.js +++ b/examples/sir/packages/sir-app/src/index.js @@ -195,7 +195,6 @@ function showGraph(graphSpec) { const legendContainer = $('#top-graph-legend') legendContainer.empty() for (const itemSpec of graphSpec.legendItems) { - console.log(itemSpec) const attrs = `class="graph-legend-item" style="background-color: ${itemSpec.color}"` const label = str(itemSpec.labelKey) const itemElem = $(`
${label}
`) From 2ef5ff0b106706513886cfed9afd95d9403f691e Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Sun, 18 Sep 2022 12:06:05 -0700 Subject: [PATCH 21/24] fix: allow for listing dat files in model.csv --- examples/sir/config/model.csv | 4 ++-- .../src/__tests__/config1/model.csv | 4 ++-- packages/plugin-config/src/context.ts | 19 ++++++++++++--- packages/plugin-config/src/processor.spec.ts | 23 +++++++++++++++++++ packages/plugin-config/src/processor.ts | 5 +--- 5 files changed, 44 insertions(+), 11 deletions(-) diff --git a/examples/sir/config/model.csv b/examples/sir/config/model.csv index 00771047..73e3eb64 100644 --- a/examples/sir/config/model.csv +++ b/examples/sir/config/model.csv @@ -1,2 +1,2 @@ -model start time,model end time,graph default min time,graph default max time -0,200,0,200 +model start time,model end time,graph default min time,graph default max time,model dat files +0,200,0,200, diff --git a/packages/plugin-config/src/__tests__/config1/model.csv b/packages/plugin-config/src/__tests__/config1/model.csv index 00771047..18a8879e 100644 --- a/packages/plugin-config/src/__tests__/config1/model.csv +++ b/packages/plugin-config/src/__tests__/config1/model.csv @@ -1,2 +1,2 @@ -model start time,model end time,graph default min time,graph default max time -0,200,0,200 +model start time,model end time,graph default min time,graph default max time,model dat files +0,200,0,200,Data1.dat;Data2.dat diff --git a/packages/plugin-config/src/context.ts b/packages/plugin-config/src/context.ts index b7b331f3..f87ec667 100644 --- a/packages/plugin-config/src/context.ts +++ b/packages/plugin-config/src/context.ts @@ -1,7 +1,7 @@ // Copyright (c) 2022 Climate Interactive / New Venture Fund import { readFileSync } from 'fs' -import { join as joinPath } from 'path' +import { join as joinPath, relative } from 'path' import parseCsv from 'csv-parse/lib/sync.js' @@ -28,7 +28,8 @@ export class ConfigContext { public readonly modelStartTime: number, public readonly modelEndTime: number, public readonly graphDefaultMinTime: number, - public readonly graphDefaultMaxTime: number + public readonly graphDefaultMaxTime: number, + public readonly datFiles: string[] ) {} /** @@ -140,6 +141,17 @@ export function createConfigContext(buildContext: BuildContext, configDir: strin const modelEndTime = Number(modelCsv['model end time']) const graphDefaultMinTime = Number(modelCsv['graph default min time']) const graphDefaultMaxTime = Number(modelCsv['graph default max time']) + const datFilesString = modelCsv['model dat files'] + const origDatFiles = datFilesString.length > 0 ? datFilesString.split(';') : [] + + // The dat file paths in the config file are assumed to be relative to + // the project directory (i.e., the directory where the `sde.config.js` + // file resides), so we need to convert to paths that are relative to + // the `sde-prep` directory (since that is the "model" directory from + // the perspective of the compile package). + const prepDir = buildContext.config.prepDir + const projDir = buildContext.config.rootDir + const datFiles = origDatFiles.map(f => joinPath(relative(prepDir, projDir), f)) // Read the static strings from `strings.csv` const strings = readStringsCsv(configDir) @@ -161,7 +173,8 @@ export function createConfigContext(buildContext: BuildContext, configDir: strin modelStartTime, modelEndTime, graphDefaultMinTime, - graphDefaultMaxTime + graphDefaultMaxTime, + datFiles ) } diff --git a/packages/plugin-config/src/processor.spec.ts b/packages/plugin-config/src/processor.spec.ts index 447f7bf1..2d3ec313 100644 --- a/packages/plugin-config/src/processor.spec.ts +++ b/packages/plugin-config/src/processor.spec.ts @@ -49,6 +49,23 @@ async function prepareForBuild(optionsFunc: (corePkgDir: string) => ConfigOption } } +const specJson1 = `\ +{ + "inputVarNames": [ + "Input A", + "Input B", + "Input C" + ], + "outputVarNames": [ + "Var 1" + ], + "externalDatfiles": [ + "../Data1.dat", + "../Data2.dat" + ] +}\ +` + const modelSpec1 = `\ // This file is generated by \`@sdeverywhere/plugin-config\`; do not edit manually! export const startTime = 0 @@ -210,6 +227,9 @@ describe('configProcessor', () => { throw new Error('Expected ok result but got: ' + result.error.message) } + const specJsonFile = joinPath(testEnv.projDir, 'sde-prep', 'spec.json') + expect(await readFile(specJsonFile, 'utf8')).toEqual(specJson1) + const modelSpecFile = joinPath(testEnv.corePkgDir, 'src', 'model', 'generated', 'model-spec.ts') expect(await readFile(modelSpecFile, 'utf8')).toEqual(modelSpec1) @@ -235,6 +255,9 @@ describe('configProcessor', () => { throw new Error('Expected ok result but got: ' + result.error.message) } + const specJsonFile = joinPath(testEnv.projDir, 'sde-prep', 'spec.json') + expect(await readFile(specJsonFile, 'utf8')).toEqual(specJson1) + const modelSpecFile = joinPath(testEnv.corePkgDir, 'mgen', 'model-spec.ts') expect(await readFile(modelSpecFile, 'utf8')).toEqual(modelSpec1) diff --git a/packages/plugin-config/src/processor.ts b/packages/plugin-config/src/processor.ts index 23fe4284..dbae10ad 100644 --- a/packages/plugin-config/src/processor.ts +++ b/packages/plugin-config/src/processor.ts @@ -116,14 +116,11 @@ async function processModelConfig(buildContext: BuildContext, options: ConfigOpt const elapsed = ((t1 - t0) / 1000).toFixed(1) context.log('info', `Done generating files (${elapsed}s)`) - // TODO: List these in model.csv - const datFiles: string[] = [] - return { startTime: context.modelStartTime, endTime: context.modelEndTime, inputs: context.getOrderedInputs(), outputs: context.getOrderedOutputs(), - datFiles + datFiles: context.datFiles } } From 2a4bd70380fdffc5d08d6310e098ce893d19b60c Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Sun, 18 Sep 2022 13:47:04 -0700 Subject: [PATCH 22/24] docs: add basic instructions --- examples/sir/config/model.csv | 2 +- packages/plugin-config/README.md | 52 ++++++++++++++++++- packages/plugin-config/package.json | 3 +- .../plugin-config/template-config/colors.csv | 3 ++ .../plugin-config/template-config/graphs.csv | 2 + .../plugin-config/template-config/inputs.csv | 3 ++ .../plugin-config/template-config/model.csv | 2 + .../plugin-config/template-config/outputs.csv | 1 + .../plugin-config/template-config/strings.csv | 4 ++ 9 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 packages/plugin-config/template-config/colors.csv create mode 100644 packages/plugin-config/template-config/graphs.csv create mode 100644 packages/plugin-config/template-config/inputs.csv create mode 100644 packages/plugin-config/template-config/model.csv create mode 100644 packages/plugin-config/template-config/outputs.csv create mode 100644 packages/plugin-config/template-config/strings.csv diff --git a/examples/sir/config/model.csv b/examples/sir/config/model.csv index 73e3eb64..c5b41a75 100644 --- a/examples/sir/config/model.csv +++ b/examples/sir/config/model.csv @@ -1,2 +1,2 @@ model start time,model end time,graph default min time,graph default max time,model dat files -0,200,0,200, +0,200,0,100, diff --git a/packages/plugin-config/README.md b/packages/plugin-config/README.md index a9eeaa83..74bd0d1b 100644 --- a/packages/plugin-config/README.md +++ b/packages/plugin-config/README.md @@ -3,9 +3,57 @@ This package provides a plugin that reads CSV files used to configure a library or app around an SDEverywhere-generated system dynamics model. -## Documentation +## Install -TODO +```sh +# npm +npm install --save-dev @sdeverywhere/plugin-config + +# pnpm +pnpm add -D @sdeverywhere/plugin-config + +# yarn +yarn add -D @sdeverywhere/plugin-config +``` + +## Usage + +To get started: + +1. Copy the included template config files to your local project: + +```sh +cd your-model-project +npm install --save-dev @sdeverywhere/plugin-config +cp -rf "./node_modules/@sdeverywhere/plugin-config/template-config" ./config +``` + +2. Replace the placeholder values in the CSV files with values that are suitable for your model. + +3. Add a line to your `sde.config.js` file that uses the `configProcessor` function supplied by this package: + +```js +import { configProcessor } from '@sdeverywhere/plugin-config' + +export async function config() { + return { + // Specify the Vensim model to read + modelFiles: ['sample.mdl'], + + // Read csv files from `config` directory and write to the `generated` directory + modelSpec: configProcessor({ + config: configDir, + out: genDir + }), + + plugins: [ + // ... + ] + } +} +``` + +4. Run `sde bundle` or `sde dev`; your config files will be used to drive the build process. ## License diff --git a/packages/plugin-config/package.json b/packages/plugin-config/package.json index b5482eb9..54f87cd7 100644 --- a/packages/plugin-config/package.json +++ b/packages/plugin-config/package.json @@ -2,7 +2,8 @@ "name": "@sdeverywhere/plugin-config", "version": "0.1.0", "files": [ - "dist/**" + "dist/**", + "template-config/**" ], "type": "module", "main": "dist/index.cjs", diff --git a/packages/plugin-config/template-config/colors.csv b/packages/plugin-config/template-config/colors.csv new file mode 100644 index 00000000..78aafaa4 --- /dev/null +++ b/packages/plugin-config/template-config/colors.csv @@ -0,0 +1,3 @@ +id,hex code,name,comment +baseline,#000000,black,baseline +current_scenario,#0000ff,blue,current scenario diff --git a/packages/plugin-config/template-config/graphs.csv b/packages/plugin-config/template-config/graphs.csv new file mode 100644 index 00000000..10a5e520 --- /dev/null +++ b/packages/plugin-config/template-config/graphs.csv @@ -0,0 +1,2 @@ +id,side,parent menu,graph title,menu title,mini title,vensim graph,kind,modes,units,alternate,unused 1,unused 2,unused 3,x axis min,x axis max,x axis label,unused 4,unused 5,y axis min,y axis max,y axis soft max,y axis label,y axis format,unused 6,unused 7,plot 1 variable,plot 1 source,plot 1 style,plot 1 label,plot 1 color,plot 1 unused 1,plot 1 unused 2,plot 2 variable,plot 2 source,plot 2 style,plot 2 label,plot 2 color,plot 2 unused 1,plot 2 unused 2,plot 3 variable,plot 3 source,plot 3 style,plot 3 label,plot 3 color,plot 3 unused 1,plot 3 unused 2,plot 4 variable,plot 4 source,plot 4 style,plot 4 label,plot 4 color,plot 4 unused 1,plot 4 unused 2,plot 5 variable,plot 5 source,plot 5 style,plot 5 label,plot 5 color,plot 5 unused 1,plot 5 unused 2,plot 6 variable,plot 6 source,plot 6 style,plot 6 label,plot 6 color,plot 6 unused 1,plot 6 unused 2,plot 7 variable,plot 7 source,plot 7 style,plot 7 label,plot 7 color,plot 7 unused 1,plot 7 unused 2,plot 8 variable,plot 8 source,plot 8 style,plot 8 label,plot 8 color,plot 8 unused 1,plot 8 unused 2,plot 9 variable,plot 9 source,plot 9 style,plot 9 label,plot 9 color,plot 9 unused 1,plot 9 unused 2,plot 10 variable,plot 10 source,plot 10 style,plot 10 label,plot 10 color,plot 10 unused 1,plot 10 unused 2,plot 11 variable,plot 11 source,plot 11 style,plot 11 label,plot 11 color,plot 11 unused 1,plot 11 unused 2,plot 12 variable,plot 12 source,plot 12 style,plot 12 label,plot 12 color,plot 12 unused 1,plot 12 unused 2,plot 13 variable,plot 13 source,plot 13 style,plot 13 label,plot 13 color,plot 13 unused 1,plot 13 unused 2,plot 14 variable,plot 14 source,plot 14 style,plot 14 label,plot 14 color,plot 14 unused 1,plot 14 unused 2,plot 15 variable,plot 15 source,plot 15 style,plot 15 label,plot 15 color,plot 15 unused 1,plot 15 unused 2,description +1,,Parent Menu 1,Graph 1 Title,,,,line,,,,,,,50,100,X-Axis,,,,300,,Y-Axis,,,,Var 1,Ref,line,Baseline,baseline,,,Var 1,,line,Current Scenario,current_scenario,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,, \ No newline at end of file diff --git a/packages/plugin-config/template-config/inputs.csv b/packages/plugin-config/template-config/inputs.csv new file mode 100644 index 00000000..504eb65a --- /dev/null +++ b/packages/plugin-config/template-config/inputs.csv @@ -0,0 +1,3 @@ +id,input type,viewid,varname,label,view level,group name,slider min,slider max,slider/switch default,slider step,units,format,reversed,range 2 start,range 3 start,range 4 start,range 5 start,range 1 label,range 2 label,range 3 label,range 4 label,range 5 label,enabled value,disabled value,controlled input ids,listing label,description +1,slider,v1,Input A,Slider A Label,,Input Group 1,-50,50,0,1,%,,,-25,-10,10,25,,lowest,low,status quo,high,highest,,,,This is a description of Slider A +2,switch,v1,Input B,Switch B Label,,Input Group 1,,,0,0,,,,,,,,,,,,,1,0,|,, diff --git a/packages/plugin-config/template-config/model.csv b/packages/plugin-config/template-config/model.csv new file mode 100644 index 00000000..e16b6362 --- /dev/null +++ b/packages/plugin-config/template-config/model.csv @@ -0,0 +1,2 @@ +model start time,model end time,graph default min time,graph default max time,model dat files +0,100,0,100,Data1.dat;Data2.dat diff --git a/packages/plugin-config/template-config/outputs.csv b/packages/plugin-config/template-config/outputs.csv new file mode 100644 index 00000000..72a4edf6 --- /dev/null +++ b/packages/plugin-config/template-config/outputs.csv @@ -0,0 +1 @@ +variable name diff --git a/packages/plugin-config/template-config/strings.csv b/packages/plugin-config/template-config/strings.csv new file mode 100644 index 00000000..086ca92d --- /dev/null +++ b/packages/plugin-config/template-config/strings.csv @@ -0,0 +1,4 @@ +id,string +__model_name,Model +__string_1,String 1 +__string_2,String 2 From 9331d101f27176efd8f8b99508ead66ab0ffb14f Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Sun, 18 Sep 2022 14:35:04 -0700 Subject: [PATCH 23/24] feat: show overlay with build messages in dev mode --- examples/sir/packages/sir-app/index.html | 2 ++ .../sir/packages/sir-app/src/dev-overlay.js | 24 +++++++++++++++ examples/sir/packages/sir-app/src/index.css | 30 +++++++++++++++++++ examples/sir/packages/sir-app/src/index.js | 2 ++ examples/sir/packages/sir-app/tsconfig.json | 5 ++-- examples/sir/packages/sir-app/vite.config.js | 4 ++- 6 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 examples/sir/packages/sir-app/src/dev-overlay.js diff --git a/examples/sir/packages/sir-app/index.html b/examples/sir/packages/sir-app/index.html index 03febc06..be4ae1cb 100644 --- a/examples/sir/packages/sir-app/index.html +++ b/examples/sir/packages/sir-app/index.html @@ -23,5 +23,7 @@
+ +
diff --git a/examples/sir/packages/sir-app/src/dev-overlay.js b/examples/sir/packages/sir-app/src/dev-overlay.js new file mode 100644 index 00000000..aee3536f --- /dev/null +++ b/examples/sir/packages/sir-app/src/dev-overlay.js @@ -0,0 +1,24 @@ +import rawMessagesHtml from '@prep/messages.html?raw' + +export const messagesHtml = rawMessagesHtml + +export function initOverlay() { + const overlayElem = document.getElementsByClassName('overlay-container')[0] + updateOverlay(overlayElem, messagesHtml) +} + +function updateOverlay(elem, messages) { + if (messages.length > 0) { + elem.innerHTML = messages + elem.style.display = 'flex' + } else { + elem.style.display = 'none' + } +} + +if (import.meta.hot) { + import.meta.hot.accept(newModule => { + const overlayElem = document.getElementsByClassName('overlay-container')[0] + updateOverlay(overlayElem, newModule.messagesHtml) + }) +} diff --git a/examples/sir/packages/sir-app/src/index.css b/examples/sir/packages/sir-app/src/index.css index 3e1ca426..f627897c 100644 --- a/examples/sir/packages/sir-app/src/index.css +++ b/examples/sir/packages/sir-app/src/index.css @@ -126,6 +126,36 @@ p { margin-left: 6px; } +/* + * Dev overlay + */ + +.overlay-container { + position: fixed; + display: none; + flex-direction: column; + flex: 1; + bottom: 0; + right: 0; + z-index: 9999; + margin-right: 10px; + margin-bottom: 10px; + max-width: 80%; + max-height: 80%; + overflow-y: auto; + padding: 10px; + border-radius: 6px; + background-color: #333; + color: #fff; + font-family: monospace; + font-size: 11px; + box-shadow: 0 3px 6px rgba(0, 0, 0, 0.8); +} + +.overlay-container .overlay-error { + color: crimson; +} + /* * Customizations for bootstrap-slider */ diff --git a/examples/sir/packages/sir-app/src/index.js b/examples/sir/packages/sir-app/src/index.js index a37170e2..f05e96bf 100644 --- a/examples/sir/packages/sir-app/src/index.js +++ b/examples/sir/packages/sir-app/src/index.js @@ -6,6 +6,7 @@ import './index.css' import { config as coreConfig, createModel } from '@core' import enStrings from '@core-strings/en' +import { initOverlay } from './dev-overlay' import { GraphView } from './graph-view' let model @@ -248,6 +249,7 @@ async function initApp() { // Initialize the user interface initInputsUI() initGraphsUI() + initOverlay() // When the model outputs are updated, refresh the graph model.onOutputsChanged = () => { diff --git a/examples/sir/packages/sir-app/tsconfig.json b/examples/sir/packages/sir-app/tsconfig.json index c08f31a8..bb6f7219 100644 --- a/examples/sir/packages/sir-app/tsconfig.json +++ b/examples/sir/packages/sir-app/tsconfig.json @@ -2,14 +2,15 @@ "compilerOptions": { // We specify the top-level directory as the root, since TypeScript requires // all referenced files to live underneath the root directory - "rootDir": "..", + "rootDir": "../..", // Make `paths` in the tsconfig files relative to the `app` directory "baseUrl": ".", // Configure path aliases "paths": { // The following lines enable path aliases within the app "@core": ["../sir-core/src"], - "@core-strings": ["../sir-core/strings"] + "@core-strings": ["../sir-core/strings"], + "@prep/*": ["../../sde-prep/*"] }, // XXX: The following two lines work around a TS/VSCode issue where this config // file shows an error ("Cannot write file appcfg.js because it would overwrite diff --git a/examples/sir/packages/sir-app/vite.config.js b/examples/sir/packages/sir-app/vite.config.js index a6f00ce2..c636b0d7 100644 --- a/examples/sir/packages/sir-app/vite.config.js +++ b/examples/sir/packages/sir-app/vite.config.js @@ -7,6 +7,7 @@ import { defineConfig } from 'vite' // Node will complain ("ERROR: __dirname is not defined in ES module scope") so // we use our own special name here const appDir = dirname(fileURLToPath(import.meta.url)) +const projDir = resolve(appDir, '..', '..') export default defineConfig(env => { return { @@ -32,7 +33,8 @@ export default defineConfig(env => { resolve: { alias: { '@core': resolve(appDir, '..', 'sir-core', 'src'), - '@core-strings': resolve(appDir, '..', 'sir-core', 'strings') + '@core-strings': resolve(appDir, '..', 'sir-core', 'strings'), + '@prep': resolve(projDir, 'sde-prep') } }, From d38fce2302ce65f14385996cc929cda09d32596b Mon Sep 17 00:00:00 2001 From: Chris Campbell Date: Sun, 18 Sep 2022 22:40:11 -0700 Subject: [PATCH 24/24] fix: copy the spec-types.ts file to the generated directory of the core package --- examples/sir/packages/sir-app/package.json | 3 ++- packages/plugin-config/package.json | 4 +++- packages/plugin-config/src/gen-config-specs.ts | 18 ++++++++++++++++++ packages/plugin-config/src/processor.spec.ts | 7 +++++++ packages/plugin-config/src/processor.ts | 3 ++- pnpm-lock.yaml | 2 ++ 6 files changed, 34 insertions(+), 3 deletions(-) diff --git a/examples/sir/packages/sir-app/package.json b/examples/sir/packages/sir-app/package.json index 9b9762eb..abb5e224 100644 --- a/examples/sir/packages/sir-app/package.json +++ b/examples/sir/packages/sir-app/package.json @@ -15,7 +15,8 @@ "dependencies": { "bootstrap-slider": "10.6.2", "chart.js": "^2.9.4", - "jquery": "^3.5.1" + "jquery": "^3.5.1", + "sir-core": "^1.0.0" }, "devDependencies": { "@types/chart.js": "^2.9.34", diff --git a/packages/plugin-config/package.json b/packages/plugin-config/package.json index 54f87cd7..3a377883 100644 --- a/packages/plugin-config/package.json +++ b/packages/plugin-config/package.json @@ -26,7 +26,9 @@ "test:watch": "vitest", "test:ci": "vitest run", "type-check": "tsc --noEmit -p tsconfig-build.json", - "build": "tsup", + "bundle": "tsup", + "copy-types": "cp src/spec-types.ts dist", + "build": "run-s bundle copy-types", "ci:build": "run-s clean lint prettier:check test:ci type-check build" }, "dependencies": { diff --git a/packages/plugin-config/src/gen-config-specs.ts b/packages/plugin-config/src/gen-config-specs.ts index ac64e487..b8d0ea89 100644 --- a/packages/plugin-config/src/gen-config-specs.ts +++ b/packages/plugin-config/src/gen-config-specs.ts @@ -1,10 +1,16 @@ // Copyright (c) 2022 Climate Interactive / New Venture Fund +import { readFileSync } from 'fs' +import { dirname, resolve as resolvePath } from 'path' +import { fileURLToPath } from 'url' + import type { ConfigContext } from './context' import { generateGraphSpecs } from './gen-graphs' import { generateInputsConfig } from './gen-inputs' import type { GraphId, GraphSpec, InputId, InputSpec } from './spec-types' +const __dirname = dirname(fileURLToPath(import.meta.url)) + export interface ConfigSpecs { graphSpecs: Map inputSpecs: Map @@ -70,3 +76,15 @@ export function writeConfigSpecs(context: ConfigContext, config: ConfigSpecs, ds // Write the `config-specs.ts` file context.writeStagedFile('config', dstDir, 'config-specs.ts', tsContent) } + +/** + * Write the `spec-types.ts` file to the given destination directory. + */ +export function writeSpecTypes(context: ConfigContext, dstDir: string): void { + // Copy the `spec-types.ts` file. Currently we keep the full source of the file + // in the `dist` directory for this package so that we can access it here. + const tsFile = 'spec-types.ts' + const tsPath = resolvePath(__dirname, tsFile) + const tsContent = readFileSync(tsPath, 'utf8') + context.writeStagedFile('config', dstDir, tsFile, tsContent) +} diff --git a/packages/plugin-config/src/processor.spec.ts b/packages/plugin-config/src/processor.spec.ts index 2d3ec313..30e6f807 100644 --- a/packages/plugin-config/src/processor.spec.ts +++ b/packages/plugin-config/src/processor.spec.ts @@ -1,5 +1,6 @@ // Copyright (c) 2022 Climate Interactive / New Venture Fund +import { existsSync } from 'fs' import { mkdir, readFile } from 'fs/promises' import { dirname, join as joinPath } from 'path' import { fileURLToPath } from 'url' @@ -236,6 +237,9 @@ describe('configProcessor', () => { const configSpecsFile = joinPath(testEnv.corePkgDir, 'src', 'config', 'generated', 'config-specs.ts') expect(await readFile(configSpecsFile, 'utf8')).toEqual(configSpecs1) + const specTypesFile = joinPath(testEnv.corePkgDir, 'src', 'config', 'generated', 'spec-types.ts') + expect(existsSync(specTypesFile)).toBe(true) + const enStringsFile = joinPath(testEnv.corePkgDir, 'strings', 'en.js') expect(await readFile(enStringsFile, 'utf8')).toEqual(enStrings1) }) @@ -264,6 +268,9 @@ describe('configProcessor', () => { const configSpecsFile = joinPath(testEnv.corePkgDir, 'cgen', 'config-specs.ts') expect(await readFile(configSpecsFile, 'utf8')).toEqual(configSpecs1) + const specTypesFile = joinPath(testEnv.corePkgDir, 'cgen', 'spec-types.ts') + expect(existsSync(specTypesFile)).toBe(true) + const enStringsFile = joinPath(testEnv.corePkgDir, 'sgen', 'en.js') expect(await readFile(enStringsFile, 'utf8')).toEqual(enStrings1) }) diff --git a/packages/plugin-config/src/processor.ts b/packages/plugin-config/src/processor.ts index dbae10ad..02d59ddf 100644 --- a/packages/plugin-config/src/processor.ts +++ b/packages/plugin-config/src/processor.ts @@ -7,7 +7,7 @@ import type { BuildContext, ModelSpec } from '@sdeverywhere/build' import { createConfigContext } from './context' import { writeModelSpec } from './gen-model-spec' -import { generateConfigSpecs, writeConfigSpecs } from './gen-config-specs' +import { generateConfigSpecs, writeConfigSpecs, writeSpecTypes } from './gen-config-specs' export interface ConfigOutputPaths { /** The absolute path to the directory where model spec files will be written. */ @@ -100,6 +100,7 @@ async function processModelConfig(buildContext: BuildContext, options: ConfigOpt if (outConfigSpecsDir) { context.log('verbose', ' Writing config specs') writeConfigSpecs(context, configSpecs, outConfigSpecsDir) + writeSpecTypes(context, outConfigSpecsDir) } if (outModelSpecsDir) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88bd0bc6..57cdb985 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,11 +113,13 @@ importers: bootstrap-slider: 10.6.2 chart.js: ^2.9.4 jquery: ^3.5.1 + sir-core: ^1.0.0 vite: ^2.9.12 dependencies: bootstrap-slider: 10.6.2 chart.js: 2.9.4 jquery: 3.6.1 + sir-core: link:../sir-core devDependencies: '@types/chart.js': 2.9.37 vite: 2.9.12