diff --git a/examples/sir/.gitignore b/examples/sir/.gitignore
new file mode 100644
index 00000000..f5c47214
--- /dev/null
+++ b/examples/sir/.gitignore
@@ -0,0 +1,4 @@
+sde-prep
+*.vdf
+*.vdfx
+*.3vmfx
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/graphs.csv b/examples/sir/config/graphs.csv
new file mode 100644
index 00000000..5df6ca2b
--- /dev/null
+++ b/examples/sir/config/graphs.csv
@@ -0,0 +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,,,,,,,,,,,,,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/config/inputs.csv b/examples/sir/config/inputs.csv
new file mode 100644
index 00000000..3298b3fc
--- /dev/null
+++ b/examples/sir/config/inputs.csv
@@ -0,0 +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,,,,,,,,,,,,,,,
+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
new file mode 100644
index 00000000..c5b41a75
--- /dev/null
+++ b/examples/sir/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,200,0,100,
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/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.check.yaml b/examples/sir/model/sir.check.yaml
new file mode 100644
index 00000000..71b6df62
--- /dev/null
+++ b/examples/sir/model/sir.check.yaml
@@ -0,0 +1,14 @@
+# 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 between 0 and 10000 for all input scenarios
+ scenarios:
+ - preset: matrix
+ datasets:
+ - name: Infectious Population I
+ - name: Recovered Population R
+ - name: Susceptible Population S
+ predicates:
+ - gte: 0
+ - lte: 10000
diff --git a/models/sir/model/sir.mdl b/examples/sir/model/sir.mdl
similarity index 96%
rename from models/sir/model/sir.mdl
rename to examples/sir/model/sir.mdl
index bb4fb9b9..8aa031cd 100755
--- a/models/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.
|
diff --git a/examples/sir/package.json b/examples/sir/package.json
new file mode 100644
index 00000000..0c55dbd8
--- /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": "^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/.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/index.html b/examples/sir/packages/sir-app/index.html
new file mode 100644
index 00000000..be4ae1cb
--- /dev/null
+++ b/examples/sir/packages/sir-app/index.html
@@ -0,0 +1,29 @@
+
+
+
+ SIR
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/sir/packages/sir-app/package.json b/examples/sir/packages/sir-app/package.json
new file mode 100644
index 00000000..abb5e224
--- /dev/null
+++ b/examples/sir/packages/sir-app/package.json
@@ -0,0 +1,25 @@
+{
+ "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",
+ "sir-core": "^1.0.0"
+ },
+ "devDependencies": {
+ "@types/chart.js": "^2.9.34",
+ "vite": "^2.9.12"
+ }
+}
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/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
new file mode 100644
index 00000000..f627897c
--- /dev/null
+++ b/examples/sir/packages/sir-app/src/index.css
@@ -0,0 +1,189 @@
+html,
+body {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ max-width: 700px;
+ height: 100%;
+ margin: 0 auto;
+ overflow: hidden;
+ box-sizing: border-box;
+ background-color: #fff;
+ font-family: Helvetica, sans-serif;
+ font-size: 13px;
+}
+
+p {
+ margin-top: 0;
+ margin-bottom: 10px;
+}
+
+/*
+ * Top panel: graphs
+ */
+
+#graphs-container {
+ padding: 0 10px;
+}
+
+#graph-selector {
+ 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-inner-container {
+ position: relative;
+ width: 100%;
+ height: 40vh;
+}
+
+#top-graph-container {
+ margin-top: 16px;
+ 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
+ */
+
+#inputs-container {
+ display: inline-flex;
+ flex-direction: column;
+ padding: 0 10px;
+ height: 100%;
+ overflow-x: hidden;
+ overflow-y: scroll;
+ background-color: #ddd;
+}
+
+.input-title-row {
+ display: flex;
+ flex-direction: row;
+ align-items: baseline;
+ margin-top: 12px;
+}
+
+.input-title {
+ flex: 1;
+ font-weight: bold;
+ font-size: 1.2em;
+ color: #111;
+}
+
+.input-value {
+ font-weight: bold;
+ font-size: 1.2em;
+ color: #111;
+}
+
+.input-units {
+ margin-left: 5px;
+ font-size: 1em;
+ color: #111;
+}
+
+.input-desc {
+ font-style: italic;
+ color: #333;
+ margin-bottom: 24px;
+}
+
+.switch-checkbox {
+ margin-bottom: 8px;
+}
+
+.switch-label {
+ font-weight: bold;
+ font-size: 1.2em;
+ color: #111;
+ vertical-align: middle;
+ 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
+ */
+
+.slider.slider-horizontal {
+ width: 94%;
+ height: 16px;
+ margin-left: 3%;
+ margin-top: 4px;
+ margin-bottom: 4px;
+}
+
+.slider.slider-horizontal .slider-track {
+ height: 8px;
+ top: 4px; /* (slider-handle:height / 2) - (slider-track:height / 2) */
+ margin-top: 0;
+}
+
+.slider-rangeHighlight {
+ background: #5588ff;
+}
+
+.slider-handle {
+ width: 16px;
+ height: 16px;
+ background: #000;
+}
+
+.slider.slider-horizontal .slider-handle {
+ margin-left: -8px;
+}
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..f05e96bf
--- /dev/null
+++ b/examples/sir/packages/sir-app/src/index.js
@@ -0,0 +1,263 @@
+import $ from 'jquery'
+import Slider from 'bootstrap-slider'
+import 'bootstrap-slider/dist/css/bootstrap-slider.css'
+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
+let graphView
+
+/**
+ * Return the base (English) string for the given key.
+ */
+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
+ */
+
+function addSliderItem(sliderInput) {
+ const spec = sliderInput.spec
+ const inputElemId = `input-${spec.id}`
+
+ const inputValue = $(``)
+ const titleRow = $(``).append([
+ $(`${str(spec.labelKey)}
`),
+ inputValue,
+ $(`${str(spec.unitsKey)}
`)
+ ])
+
+ const div = $(``).append([
+ titleRow,
+ $(``),
+ $(`${spec.descriptionKey ? str(spec.descriptionKey) : ''}
`)
+ ])
+
+ $('#inputs-content').append(div)
+
+ const value = sliderInput.get()
+ const slider = new Slider(`#${inputElemId}`, {
+ value,
+ min: spec.minValue,
+ max: spec.maxValue,
+ step: spec.step,
+ reversed: spec.reversed,
+ tooltip: 'hide',
+ selection: 'none',
+ rangeHighlights: [{ start: spec.defaultValue, end: value }]
+ })
+
+ // Show the initial value and update the value when the slider is changed
+ const updateValueElement = v => {
+ inputValue.text(format(v, spec.format))
+ }
+ 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)
+ })
+}
+
+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 = 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 = 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 = model.getInputForId(sliderId)
+ addSliderItem(slider)
+ }
+ }
+}
+
+/**
+ * Initialize the UI for the inputs menu and panel.
+ */
+function initInputsUI() {
+ $('#inputs-content').empty()
+ for (const inputId of coreConfig.inputs.keys()) {
+ const input = model.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 model.getSeriesForVar(varId, sourceName)
+ },
+ getStringForKey: key => {
+ // TODO: Inject values if string is templated
+ return str(key)
+ },
+ formatYAxisTickValue: value => {
+ return format(value, graphSpec.yFormat)
+ }
+ }
+}
+
+function showGraph(graphSpec) {
+ if (graphView) {
+ // Destroy the old view before switching to a new one
+ graphView.destroy()
+ }
+
+ // Create a new GraphView that targets the canvas element
+ 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)
+
+ // Show the legend items for the graph
+ const legendContainer = $('#top-graph-legend')
+ legendContainer.empty()
+ for (const itemSpec of graphSpec.legendItems) {
+ 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) {
+ 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()
+ } catch (e) {
+ console.error(`ERROR: Failed to load model: ${e.message}`)
+ return
+ }
+
+ // Initialize the user interface
+ initInputsUI()
+ initGraphsUI()
+ initOverlay()
+
+ // When the model outputs are updated, refresh the graph
+ model.onOutputsChanged = () => {
+ if (graphView) {
+ graphView.updateData()
+ }
+ }
+}
+
+// Initialize the app when this script is loaded
+initApp()
diff --git a/examples/sir/packages/sir-app/tsconfig.json b/examples/sir/packages/sir-app/tsconfig.json
new file mode 100644
index 00000000..bb6f7219
--- /dev/null
+++ b/examples/sir/packages/sir-app/tsconfig.json
@@ -0,0 +1,30 @@
+{
+ "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"],
+ "@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
+ // 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-app/vite.config.js b/examples/sir/packages/sir-app/vite.config.js
new file mode 100644
index 00000000..c636b0d7
--- /dev/null
+++ b/examples/sir/packages/sir-app/vite.config.js
@@ -0,0 +1,67 @@
+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))
+const projDir = resolve(appDir, '..', '..')
+
+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'),
+ '@core-strings': resolve(appDir, '..', 'sir-core', 'strings'),
+ '@prep': resolve(projDir, 'sde-prep')
+ }
+ },
+
+ 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/.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/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/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.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..66af1e9f
--- /dev/null
+++ b/examples/sir/packages/sir-core/src/model/model.ts
@@ -0,0 +1,152 @@
+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
+ 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/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..2445e086
--- /dev/null
+++ b/examples/sir/sde.config.js
@@ -0,0 +1,61 @@
+import { dirname, join as joinPath } from 'path'
+import { fileURLToPath } from 'url'
+
+import { checkPlugin } from '@sdeverywhere/plugin-check'
+import { configProcessor } from '@sdeverywhere/plugin-config'
+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'],
+
+ // The following files will be hashed to determine whether the model needs
+ // to be rebuilt when watch mode is active
+ 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/*.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')]
+ }),
+
+ // Run model check
+ checkPlugin(),
+
+ // Build or serve the model explorer app
+ vitePlugin({
+ name: `${baseName}-app`,
+ apply: {
+ development: 'serve'
+ },
+ config: {
+ configFile: appPath('vite.config.js')
+ }
+ })
+ ]
+ }
+}
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 d7fe0f63..00000000
Binary files a/models/sir/model/logo.png and /dev/null differ
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 {
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))
diff --git a/packages/plugin-config/.eslintignore b/packages/plugin-config/.eslintignore
new file mode 100644
index 00000000..1521c8b7
--- /dev/null
+++ b/packages/plugin-config/.eslintignore
@@ -0,0 +1 @@
+dist
diff --git a/packages/plugin-config/.eslintrc.cjs b/packages/plugin-config/.eslintrc.cjs
new file mode 100644
index 00000000..3a98c61d
--- /dev/null
+++ b/packages/plugin-config/.eslintrc.cjs
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: ['../../.eslintrc-ts-common.cjs']
+}
diff --git a/packages/plugin-config/.gitignore b/packages/plugin-config/.gitignore
new file mode 100644
index 00000000..5e9b0cb2
--- /dev/null
+++ b/packages/plugin-config/.gitignore
@@ -0,0 +1,2 @@
+dist
+docs/entry.md
diff --git a/packages/plugin-config/.prettierignore b/packages/plugin-config/.prettierignore
new file mode 100644
index 00000000..bfdb68de
--- /dev/null
+++ b/packages/plugin-config/.prettierignore
@@ -0,0 +1,3 @@
+dist
+docs
+CHANGELOG.md
diff --git a/packages/plugin-config/LICENSE b/packages/plugin-config/LICENSE
new file mode 100644
index 00000000..29bed4e9
--- /dev/null
+++ b/packages/plugin-config/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/README.md b/packages/plugin-config/README.md
new file mode 100644
index 00000000..74bd0d1b
--- /dev/null
+++ b/packages/plugin-config/README.md
@@ -0,0 +1,60 @@
+# @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.
+
+## Install
+
+```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
+
+SDEverywhere is distributed under the MIT license. See `LICENSE` for more details.
diff --git a/packages/plugin-config/package.json b/packages/plugin-config/package.json
new file mode 100644
index 00000000..3a377883
--- /dev/null
+++ b/packages/plugin-config/package.json
@@ -0,0 +1,61 @@
+{
+ "name": "@sdeverywhere/plugin-config",
+ "version": "0.1.0",
+ "files": [
+ "dist/**",
+ "template-config/**"
+ ],
+ "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",
+ "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": {
+ "@sdeverywhere/build": "^0.1.1",
+ "byline": "^5.0.0",
+ "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"
+ },
+ "author": "Climate Interactive",
+ "license": "MIT",
+ "homepage": "https://sdeverywhere.org",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/climateinteractive/SDEverywhere.git",
+ "directory": "packages/plugin-config"
+ },
+ "bugs": {
+ "url": "https://github.com/climateinteractive/SDEverywhere/issues"
+ }
+}
diff --git a/packages/plugin-config/src/__tests__/config1/colors.csv b/packages/plugin-config/src/__tests__/config1/colors.csv
new file mode 100644
index 00000000..78aafaa4
--- /dev/null
+++ b/packages/plugin-config/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/src/__tests__/config1/graphs.csv b/packages/plugin-config/src/__tests__/config1/graphs.csv
new file mode 100644
index 00000000..10a5e520
--- /dev/null
+++ b/packages/plugin-config/src/__tests__/config1/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/src/__tests__/config1/inputs.csv b/packages/plugin-config/src/__tests__/config1/inputs.csv
new file mode 100644
index 00000000..0fe5d2b0
--- /dev/null
+++ b/packages/plugin-config/src/__tests__/config1/inputs.csv
@@ -0,0 +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,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/src/__tests__/config1/model.csv b/packages/plugin-config/src/__tests__/config1/model.csv
new file mode 100644
index 00000000..18a8879e
--- /dev/null
+++ b/packages/plugin-config/src/__tests__/config1/model.csv
@@ -0,0 +1,2 @@
+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/__tests__/config1/outputs.csv b/packages/plugin-config/src/__tests__/config1/outputs.csv
new file mode 100644
index 00000000..72a4edf6
--- /dev/null
+++ b/packages/plugin-config/src/__tests__/config1/outputs.csv
@@ -0,0 +1 @@
+variable name
diff --git a/packages/plugin-config/src/__tests__/config1/strings.csv b/packages/plugin-config/src/__tests__/config1/strings.csv
new file mode 100644
index 00000000..75dc1672
--- /dev/null
+++ b/packages/plugin-config/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/src/context.ts b/packages/plugin-config/src/context.ts
new file mode 100644
index 00000000..f87ec667
--- /dev/null
+++ b/packages/plugin-config/src/context.ts
@@ -0,0 +1,220 @@
+// Copyright (c) 2022 Climate Interactive / New Venture Fund
+
+import { readFileSync } from 'fs'
+import { join as joinPath, relative } from 'path'
+
+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'
+
+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 graphDefaultMinTime: number,
+ public readonly graphDefaultMaxTime: number,
+ public readonly datFiles: string[]
+ ) {}
+
+ /**
+ * 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.
+ *
+ * @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)
+ }
+
+ 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
+ // 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(dstDir: string): void {
+ this.strings.writeJsFiles(this.buildContext, dstDir /*, xlatLangs*/)
+ }
+}
+
+export function createConfigContext(buildContext: BuildContext, configDir: string): ConfigContext {
+ // Read basic model configuration from `model.csv`
+ 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 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)
+
+ // 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,
+ configDir,
+ strings,
+ colors,
+ modelStartTime,
+ modelEndTime,
+ graphDefaultMinTime,
+ graphDefaultMaxTime,
+ datFiles
+ )
+}
+
+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()
+
+ // 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/src/gen-config-specs.ts b/packages/plugin-config/src/gen-config-specs.ts
new file mode 100644
index 00000000..b8d0ea89
--- /dev/null
+++ b/packages/plugin-config/src/gen-config-specs.ts
@@ -0,0 +1,90 @@
+// 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
+}
+
+/**
+ * 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 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
+ 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`; do not edit manually!')
+ emit('')
+ emit(`import type { GraphSpec, InputSpec } from './spec-types'`)
+
+ // 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)
+}
+
+/**
+ * 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/gen-graphs.ts b/packages/plugin-config/src/gen-graphs.ts
new file mode 100644
index 00000000..e4636bbf
--- /dev/null
+++ b/packages/plugin-config/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,
+ StringKey,
+ UnitSystem
+} from './spec-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/src/gen-inputs.ts b/packages/plugin-config/src/gen-inputs.ts
new file mode 100644
index 00000000..529dec79
--- /dev/null
+++ b/packages/plugin-config/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'), 'input-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/src/gen-model-spec.ts b/packages/plugin-config/src/gen-model-spec.ts
new file mode 100644
index 00000000..62983263
--- /dev/null
+++ b/packages/plugin-config/src/gen-model-spec.ts
@@ -0,0 +1,28 @@
+// Copyright (c) 2022 Climate Interactive / New Venture Fund
+
+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`; 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/src/index.ts b/packages/plugin-config/src/index.ts
new file mode 100644
index 00000000..6a4411c7
--- /dev/null
+++ b/packages/plugin-config/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/src/processor.spec.ts b/packages/plugin-config/src/processor.spec.ts
new file mode 100644
index 00000000..30e6f807
--- /dev/null
+++ b/packages/plugin-config/src/processor.spec.ts
@@ -0,0 +1,277 @@
+// 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'
+
+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')
+ 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'],
+ logLevels: [],
+ sdeDir: '',
+ sdeCmdPath: ''
+ }
+
+ return {
+ projDir,
+ corePkgDir,
+ buildOptions
+ }
+}
+
+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
+export const endTime = 200
+export const inputVarIds: string[] = [
+ "_input_a",
+ "_input_b",
+ "_input_c"
+]
+export const outputVarIds: string[] = [
+ "_var_1"
+]
+`
+
+const configSpecs1 = `\
+// This file is generated by \`@sdeverywhere/plugin-config\`; do not edit manually!
+
+import type { GraphSpec, InputSpec } 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"
+ }
+ ]
+ }
+]
+
+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": []
+ }
+]
+`
+
+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()
+ })
+
+ 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 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)
+
+ 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)
+ })
+
+ 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 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)
+
+ 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
new file mode 100644
index 00000000..02d59ddf
--- /dev/null
+++ b/packages/plugin-config/src/processor.ts
@@ -0,0 +1,127 @@
+// Copyright (c) 2022 Climate Interactive / New Venture Fund
+
+import { existsSync } from 'fs'
+import { join as joinPath } from 'path'
+
+import type { BuildContext, ModelSpec } from '@sdeverywhere/build'
+
+import { createConfigContext } from './context'
+import { writeModelSpec } from './gen-model-spec'
+import { generateConfigSpecs, writeConfigSpecs, writeSpecTypes } from './gen-config-specs'
+
+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...')
+
+ const configSpecs = generateConfigSpecs(context)
+ if (outConfigSpecsDir) {
+ context.log('verbose', ' Writing config specs')
+ writeConfigSpecs(context, configSpecs, outConfigSpecsDir)
+ writeSpecTypes(context, outConfigSpecsDir)
+ }
+
+ if (outModelSpecsDir) {
+ context.log('verbose', ' Writing model specs')
+ writeModelSpec(context, outModelSpecsDir)
+ }
+
+ if (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)`)
+
+ return {
+ startTime: context.modelStartTime,
+ endTime: context.modelEndTime,
+ inputs: context.getOrderedInputs(),
+ outputs: context.getOrderedOutputs(),
+ datFiles: context.datFiles
+ }
+}
diff --git a/packages/plugin-config/src/read-config.ts b/packages/plugin-config/src/read-config.ts
new file mode 100644
index 00000000..49b2c009
--- /dev/null
+++ b/packages/plugin-config/src/read-config.ts
@@ -0,0 +1,17 @@
+// Copyright (c) 2022 Climate Interactive / New Venture Fund
+
+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/src/spec-types.ts b/packages/plugin-config/src/spec-types.ts
new file mode 100644
index 00000000..4dcda17e
--- /dev/null
+++ b/packages/plugin-config/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/src/strings.ts b/packages/plugin-config/src/strings.ts
new file mode 100644
index 00000000..2dceb591
--- /dev/null
+++ b/packages/plugin-config/src/strings.ts
@@ -0,0 +1,328 @@
+// Copyright (c) 2021-2022 Climate Interactive / New Venture Fund
+
+import sanitizeHtml from 'sanitize-html'
+
+import type { BuildContext } from '@sdeverywhere/build'
+
+import type { StringKey } from './spec-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 '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}`)
+ }
+ }
+
+ // 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 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 {
+ 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 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)))
+
+ // 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}`
+}
+
+/**
+ * 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}`)
+ }
+}
diff --git a/packages/plugin-config/src/var-names.ts b/packages/plugin-config/src/var-names.ts
new file mode 100644
index 00000000..763a36f9
--- /dev/null
+++ b/packages/plugin-config/src/var-names.ts
@@ -0,0 +1,49 @@
+// Copyright (c) 2022 Climate Interactive / New Venture Fund
+
+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/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
diff --git a/packages/plugin-config/tsconfig-base.json b/packages/plugin-config/tsconfig-base.json
new file mode 100644
index 00000000..01a7d112
--- /dev/null
+++ b/packages/plugin-config/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/tsconfig-build.json b/packages/plugin-config/tsconfig-build.json
new file mode 100644
index 00000000..c16db5ed
--- /dev/null
+++ b/packages/plugin-config/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/tsconfig-test.json b/packages/plugin-config/tsconfig-test.json
new file mode 100644
index 00000000..7bd4cad8
--- /dev/null
+++ b/packages/plugin-config/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/tsconfig.json b/packages/plugin-config/tsconfig.json
new file mode 100644
index 00000000..fc8b16a2
--- /dev/null
+++ b/packages/plugin-config/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/tsup.config.ts b/packages/plugin-config/tsup.config.ts
new file mode 100644
index 00000000..7b62ef95
--- /dev/null
+++ b/packages/plugin-config/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..57cdb985 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -91,6 +91,47 @@ 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': ^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': 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
+
+ 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
+ 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
+
+ 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 +277,35 @@ importers:
devDependencies:
'@types/node': 16.11.40
+ packages/plugin-config:
+ 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/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
+
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
@@ -547,12 +631,24 @@ 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:
'@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 +914,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 +1161,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'}
@@ -1108,10 +1212,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:
@@ -1360,7 +1490,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==}
@@ -1839,6 +1968,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'}
@@ -1984,6 +2121,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
@@ -2035,6 +2177,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
@@ -2401,6 +2547,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}
@@ -2677,6 +2827,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
@@ -2726,6 +2883,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'}
@@ -3172,6 +3340,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..395ccd76 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,4 +1,6 @@
packages:
- packages/*
- examples/*
+ # TODO: This next line can be removed once we remove sir/packages
+ - examples/sir/packages/*
- tests