diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..be9efd7 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,34 @@ +name: docs + +on: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: make init + - run: make test + - run: make docs + - uses: actions/upload-pages-artifact@v3 + with: + path: docs/html/ + + deploy: + needs: build + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 290c3f1..bed8153 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,9 @@ *todo* -dist/* -tests/* -docs/*.html -docs/*.css -docs/dist -docs/plot + +deps +dist +docs/html +node_modules test/flottplot.js test/*.json -node_modules diff --git a/Makefile b/Makefile index a170eb3..08beea7 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,7 @@ dist: \ dist/flottplot.css clean: + rm -rf deps rm -rf dist rm -rf docs/*.html rm -rf docs/*.css @@ -19,19 +20,32 @@ clean: %/: mkdir -p $@ +init: + npm install --include dev + pip install -r docs/requirements.txt + # Flottplot modules dist/%.css: src/%.less | dist/ npx lessc $< > $@ -dist/%-min.js: src/bundles/%.ts | dist/ +# Building a bundle with webpack: +# - bundle js file depends on its corresponding ts file only +# - ts file dependencies are auto-generated +# - webpack won't overwrite the js file if it's content hasn't changed, +# touching it anyway after successful compilation ensures that make +# recognizes the update +dist/%-min.js: src/bundles/%.ts | dist/ deps/ + python3 util/generate_dependencies.py $< > deps/$*.mk npx webpack build \ --entry-reset \ --entry "./$<" \ - --output-filename "$(notdir $@)" + --output-filename "$(notdir $@)" \ + && touch $@ -# TODO: ts file dependencies +# Auto-generated dependencies for bundle files +-include deps/*.mk # Unit tests @@ -44,56 +58,58 @@ test: test/flottplot.js test/format_cases.json $(TESTS) test/format_cases.json: util/generate_format_cases.py | test/ python3 $< > $@ -test/flottplot.js: src/bundles/flottplot-test.ts +test/flottplot.js: src/bundles/flottplot-test.ts | deps/ + python3 util/generate_dependencies.py $< > deps/flottplot-test.mk npx webpack build \ --entry-reset \ --entry "./$<" \ --output-library-type "commonjs2" \ --output-path "test" \ - --output-filename "flottplot.js" + --output-filename "flottplot.js" \ + && touch $@ # Documentation -DOCS := \ - docs/ \ - docs/index.html \ - docs/tutorial.html \ - docs/elements.html \ - docs/values.html \ - docs/python.html \ - docs/docs.css \ - docs/convert.js \ - docs/dist/flottplot-min.js \ - docs/dist/flottplot.css \ - docs/dist/flottplot-scan-min.js \ - docs/plot/sin-1x.png \ - docs/plot/sin-2x.png \ - docs/plot/sin-3x.png \ - docs/plot/cos-3x.png \ - docs/plot/cos-2x.png \ - docs/plot/cos-3x.png \ - docs/plot/adv_fwd_000.png \ - docs/plot/adv_bwd_000.png \ - docs/plot/adv_lag_000.png - -docs: $(DOCS) - -docs/%.css: docs/src/%.less +DOCS_HTML := \ + docs/html/ \ + docs/html/index.html \ + docs/html/tutorial.html \ + docs/html/elements.html \ + docs/html/values.html \ + docs/html/python.html \ + docs/html/docs.css \ + docs/html/convert.js \ + docs/html/dist/flottplot-min.js \ + docs/html/dist/flottplot.css \ + docs/html/dist/flottplot-scan-min.js \ + docs/html/plot/sin-1x.png \ + docs/html/plot/sin-2x.png \ + docs/html/plot/sin-3x.png \ + docs/html/plot/cos-3x.png \ + docs/html/plot/cos-2x.png \ + docs/html/plot/cos-3x.png \ + docs/html/plot/adv_fwd_000.png \ + docs/html/plot/adv_bwd_000.png \ + docs/html/plot/adv_lag_000.png + +docs: $(DOCS_HTML) + +docs/html/%.css: docs/src/%.less npx lessc $< $@ -docs/%.html: docs/util/build.py docs/src/template.html docs/src/%.html +docs/html/%.html: docs/util/build.py docs/src/template.html docs/src/%.html python3 $+ > $@ -docs/dist/%: dist/% | docs/dist/ +docs/html/dist/%: dist/% | docs/html/dist/ cp $^ $@ -docs/plot/sin-%x.png: docs/util/plot-trigonometric.py | docs/plot/ +docs/html/plot/sin-%x.png: docs/util/plot-trigonometric.py | docs/html/plot/ python3 $< "sin" $* $@ -docs/plot/cos-%x.png: docs/util/plot-trigonometric.py | docs/plot/ +docs/html/plot/cos-%x.png: docs/util/plot-trigonometric.py | docs/html/plot/ python3 $< "cos" $* $@ -docs/plot/adv_%_000.png: docs/util/plot-advection.py | docs/plot/ +docs/html/plot/adv_%_000.png: docs/util/plot-advection.py | docs/html/plot/ python3 $< $* $(dir $@) diff --git a/docs/.nojekyll b/docs/.nojekyll deleted file mode 100644 index 8d1c8b6..0000000 --- a/docs/.nojekyll +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/convert.js b/docs/html/convert.js similarity index 100% rename from docs/convert.js rename to docs/html/convert.js diff --git a/docs/favicon.png b/docs/html/favicon.png similarity index 100% rename from docs/favicon.png rename to docs/html/favicon.png diff --git a/docs/logo.svg b/docs/html/logo.svg similarity index 100% rename from docs/logo.svg rename to docs/html/logo.svg diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..0399ef5 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,4 @@ +numpy +matplotlib +flottplot + diff --git a/docs/src/docs.less b/docs/src/docs.less index 74ed603..b127d1c 100644 --- a/docs/src/docs.less +++ b/docs/src/docs.less @@ -14,12 +14,6 @@ body { color: #000; font-family: sans-serif; } -div.row { - display: flex; - flex-wrap: wrap; - max-width: 1300px; -} - a { color: darken(@def-dark, 20%); text-decoration: underline; @@ -29,114 +23,148 @@ a:hover { text-decoration: none; } -div.sidebar { - flex-grow: 1; - width: 200px; - height: 100vh; - position: sticky; - top: 0; - background-color: #EEE; - padding: 0 10px; -} -div.sidebar > ul { list-style: none; padding-left: 0; margin: 0; } -div.sidebar > ul > li { padding: 3px 0; margin-bottom: 5px; } -div.sidebar > ul > li > a { font-weight: bold; } -div.sidebar > ul > li > ul { list-style: none; padding-left: 15px; } -div.sidebar > ul > li > ul > li { } -div.sidebar a { color: #000; display: block; text-decoration: none; } -div.sidebar a:hover { color: @def-dark; } - -div.content { - flex-basis: 0; - flex-grow: 999; - min-inline-size: 600px; - padding: 10px; - background-color: #FFF; - z-index: 10; +div.row { + display: flex; + flex-wrap: wrap; + max-width: 1300px; + + .sidebar { + flex-grow: 1; + width: 200px; + background-color: #EEE; + padding: 10px; + } + + .content { + flex-basis: 0; + flex-grow: 999; + min-width: 600px; + padding: 10px 10px 10px 20px; + background-color: #FFF; + z-index: 10; + } } -div.header { - display: block; - margin: 10px -10px 10px 0; - padding: 0 10px 10px 0; +.ui-logo { border-bottom: 2px solid #999; - color: #999; - height: 30px; -} -#logo { - height: 30px; - max-width: 100%; + + img { + display: block; + height: 30px; + max-width: 100%; + } } -#navbar { + +.ui-nav { font-size: 130%; - margin: 0 0 10px -10px; - padding: 0px 0 10px 10px; border-color: #CCC; text-align: right; -} -#navbar a { - display: inline-block; - padding: 0 7px; - color: black; - text-decoration: none; -} -#navbar a:hover { - color: @def-dark; -} + border-bottom: 2px solid #CCC; -main { padding: 0 0 0 10px; margin: 0; } + a { + display: inline-block; + padding: 0 7px; + color: black; + text-decoration: none; + } + a:hover { + color: @def-dark; + } -main h1 { - font-size: 200%; - margin: 30px 0 10px 0; - font-weight: normal; -} -main h2 { - font-size: 140%; - margin: 30px 0 10px 0; - border-bottom: 2px solid #CCC; - padding-bottom: 5px; - font-weight: normal; } -main h3 { - font-size: 100%; - margin: 20px 0 10px 0; -} -main ul { - margin: 10px 0; - padding-left: 30px; - list-style: none; -} -main p { - margin: 10px 0; -} -main p:first-child { - margin-top: revert; -} -main ul>li::before { - content: "●"; /* Add content: \2022 is the CSS Code/unicode for a bullet */ - display: inline-block; /* Needed to add space between the bullet and the text */ - width: 20px; /* Also needed for space (tweak if needed) */ - margin-left: -20px; /* Also needed for space (tweak if needed) */ - color: #999; -} -main ul li ul { - margin: 0 0 10px 0; -} -main pre { - margin: 10px 0; - font-size: 1.1em; - overflow: scroll; - padding: 8px 10px; - border-radius: 3px; - background-color: #EEE; + +.ui-toc { + /* wrap happens at 200px (sidebar) + 600px (content) + 50px (padding) */ + @media only screen and (min-width: 850px) { + height: calc(100vh - 20px); + position: sticky; + top: 0; + overflow-y: scroll; + } + + > ul { + list-style: none; + padding-left: 0; + margin: 0; + > li { + padding: 3px 0; + margin-bottom: 5px; + > a { + font-weight: bold; + } + > ul { + list-style: none; + padding-left: 15px; + } + } + } + + a { + color: #000; + display: block; + text-decoration: none; + } + a:hover { + color: @def-dark; + } } -main code { - display: inline-block; - background-color: #EEE; - font-size: 1.2em; - padding: 0 4px; - border-radius: 3px; + +.ui-main { + h1 { + font-size: 200%; + margin: 30px 0 10px 0; + font-weight: normal; + } + h2 { + font-size: 140%; + margin: 30px 0 10px 0; + border-bottom: 2px solid #CCC; + padding-bottom: 5px; + font-weight: normal; + } + h3 { + font-size: 100%; + margin: 20px 0 10px 0; + } + + ul { + margin: 10px 0; + padding-left: 30px; + list-style: none; + > li::before { + content: "●"; /* Add content: \2022 is the CSS Code/unicode for a bullet */ + display: inline-block; /* Needed to add space between the bullet and the text */ + width: 20px; /* Also needed for space (tweak if needed) */ + margin-left: -20px; /* Also needed for space (tweak if needed) */ + color: #999; + } + > li ul { + margin: 0 0 10px 0; + } + } + + p { + margin: 10px 0; + } + p:first-child { + margin-top: revert; + } + + pre { + margin: 10px 0; + font-size: 1.1em; + overflow-x: scroll; + padding: 8px 10px; + border-radius: 3px; + background-color: #EEE; + } + code { + display: inline-block; + background-color: #EEE; + font-size: 1.2em; + padding: 0 4px; + border-radius: 3px; + } } .downloads a { @@ -148,46 +176,49 @@ main code { h3 { color: @def-dark; } - >ul>li::before { + > ul > li::before { color: @def-dark; } - pre, >ul>li>code:first-child { + pre, > ul > li > code:first-child { background-color: @def-light; } } + .val { border-color: @val-light; h3 { color: @val-dark; } - >ul>li::before { + > ul > li::before { color: @val-dark; } - pre, >ul>li>code:first-child { + pre, > ul > li > code:first-child { background-color: @val-light; } } + .act { border-color: @act-light; h3 { color: @act-dark; } - >ul>li::before { + > ul > li::before { color: @act-dark; } - pre, >ul>li>code:first-child { + pre, > ul > li > code:first-child { background-color: @act-light; } } + .demo { border-color: @demo-light; h3 { color: @demo-dark; } - >ul>li::before { + > ul > li::before { color: @demo-dark; } - pre, >ul>li>code:first-child { + pre, > ul > li > code:first-child { background-color: @demo-light; } } diff --git a/docs/src/elements.html b/docs/src/elements.html index 9a92839..fd4880c 100644 --- a/docs/src/elements.html +++ b/docs/src/elements.html @@ -682,5 +682,15 @@

Actions

+ +
+

Demo

+
+<fp-range id="x" type="dropdown" min="0" max="15" step="1"></fp-range>
+<fp-range id="y" type="dropdown" min="0" max="15" step="1"></fp-range>
+<fp-state id="s"></fp-state>
+<fp-controls target="s"></fp-controls>
+
+
diff --git a/docs/src/template.html b/docs/src/template.html index c79aca0..b6fade4 100644 --- a/docs/src/template.html +++ b/docs/src/template.html @@ -10,26 +10,25 @@ - +
+ +
+ Start + Tutorial + Elements + Values + Python +
+
- diff --git a/docs/src/values.html b/docs/src/values.html index e60a4f5..78f42fa 100644 --- a/docs/src/values.html +++ b/docs/src/values.html @@ -45,10 +45,10 @@

Binary Operators

Concatenation.
  • § (Number, Text) → Text
    - Left slice. + Left slice: only retain characters after the position specified by the left argument (positive: count from left; negative: count from right).
  • § (Text, Number) → Text
    - Right slice. + Right slice: only retain characters before the position specified by the right argument (positive: count from left; negative: count from right).
  • @@ -56,8 +56,10 @@

    Binary Operators

    Examples

    @@ -202,13 +204,14 @@

    Formatting

  • %%: A literal percent character.
  • - If no specification is given, Date values default to %Y-%m-%d %H:%M:%S. + If no specification is given, Date values default to the original input text or %Y-%m-%d %H:%M:%S if none exists.

    Examples

    @@ -290,7 +293,7 @@

    Examples

    Formatting

    DateDelta does not provide formatting options at the moment. - By default values are formatted as +Dd HH:MM:SS. + By default values are returned as they were input or are formatted as +Dd HH:MM:SS if no original input exists. Use attributes to assemble a custom format.

    diff --git a/package.json b/package.json index 114c708..885504c 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "devDependencies": { - "less": "^4.1.3", - "mocha": "^10.2.0", - "ts-loader": "^9.4.2", - "typescript": "^5.0.4", - "webpack": "^5.82.0", - "webpack-cli": "^5.1.0" + "less": "^4.2.2", + "mocha": "^11.1.0", + "ts-loader": "^9.5.2", + "typescript": "^5.7.3", + "webpack": "^5.97.1", + "webpack-cli": "^6.0.1" } } diff --git a/src/bundles/flottplot.ts b/src/bundles/flottplot.ts index 4614214..54b0b3e 100644 --- a/src/bundles/flottplot.ts +++ b/src/bundles/flottplot.ts @@ -14,6 +14,8 @@ import { FPCursors } from "../elements/cursors"; import { FPOverlay } from "../elements/overlay"; import { FPPlot } from "../elements/plot"; import { FPStack } from "../elements/stack"; +import { FPState } from "../elements/state"; +import { FPText } from "../elements/text"; import { FPVideo } from "../elements/video"; import { rangeFrom, selectFrom } from "../elements/items" @@ -28,26 +30,15 @@ Flottplot.registerTag("fp-plot", FPPlot.from, false); Flottplot.registerTag("fp-range", rangeFrom, false); Flottplot.registerTag("fp-select", selectFrom, false); Flottplot.registerTag("fp-stack", FPStack.from, true); +Flottplot.registerTag("fp-state", FPState.from, false); +Flottplot.registerTag("fp-text", FPText.from, true); Flottplot.registerTag("fp-video", FPVideo.from, false); // ... export { VERSION, Flottplot, ElementMixin, Value, FlottplotError, dom }; // TODO return { -// TODO VERSION = "2.2.0", -// TODO Flottplot: Flottplot, -// TODO FPElement: FPElement, -// TODO Value: Value, // TODO OptionsItems: OptionsItems, // TODO RangeItems: RangeItems, -// TODO FlottplotError: FlottplotError, -// TODO ElementError: ElementError, -// TODO dom: dom, // TODO }; - -// Create element registry and initialize with core elements - -// TODO Flottplot.registerTag("fp-state", FPState.from, false); -// TODO Flottplot.registerTag("fp-text", FPText.from, true); - diff --git a/src/element.ts b/src/element.ts index 168f77a..b22e178 100644 --- a/src/element.ts +++ b/src/element.ts @@ -1,4 +1,4 @@ -import { Identifier, Action, Expression, Manager, Pattern, Substitution } from "./interface"; +import { Identifier, Action, Expression, Manager, Pattern, FormatSpec, Substitution } from "./interface"; import { ElementError } from "./errors"; import { Expr } from "./expression"; import { Value } from "./values"; @@ -19,7 +19,7 @@ export class ElementMixin { readonly id: Identifier; node: HTMLElement | null; - patterns: Map; + patterns: Map; dependencies: Set; actions: Set; private _manager: Manager | null; @@ -34,8 +34,7 @@ export class ElementMixin { } else if (/^[A-Za-z][A-Za-z0-9_]*$/.test(id)) { this.id = id; } else throw new ElementError( - "invalid id '" + id + "' for " + this.constructor.name - + " (names must begin with A-z and only contain A-z, 0-9 and _)" + `invalid element id '${id}' (names must begin with A-z and only contain A-z, 0-9 and _)` ); this.node = null; // Element is initially not connected to a flottplot supervisor. This @@ -64,14 +63,11 @@ export class ElementMixin { this._manager = manager; } - // Throw an error, provides additional context generated for this element - fail(message: string): void { - this.failWith(new ElementError( - "in " + this.constructor.name + " '" + this.id + "': " + message - )); - } - - failWith(error: Error) { + warn(message: string | Error): ElementError { + if (message instanceof Error) { + message = message.message; + } + const error = new ElementError(`in element '${this.id}': ${message}`); if (this.node != null) { if (this._errorBox == null) { this._errorBox = newNode("div", { @@ -95,9 +91,12 @@ export class ElementMixin { // Condition ensures there is at least one child, skip null check this._errorBox.firstChild!.remove(); } - } else { - throw error; } + return error; + } + + fail(message: string | Error): never { + throw this.warn(message); } // Invoke an action of the element, update the element and notify all @@ -141,7 +140,7 @@ export class ElementMixin { // them into the patterns and dependencies attributes of this element so // the supervisor can provide appropriate substitutions in updates setDependenciesFrom(...templates: Array): void { - this.patterns = new Map(); + this.patterns = new Map(); this.dependencies = new Set(); for (const template of templates) { const reg = /{.+?}/g; @@ -151,7 +150,8 @@ export class ElementMixin { break } const pattern = match[0]; - const [patExpr, format] = pattern.slice(1, -1).split(":"); + const [patExpr, ...formatParts] = pattern.slice(1, -1).split(":"); + const format = (formatParts.length > 0) ? formatParts.join(":") : undefined; const expression = Expr.parse(patExpr); // format will have undefined assigned if not given this.patterns.set(pattern, [expression, format]); diff --git a/src/elements/animation.ts b/src/elements/animation.ts index fae197e..c6bbd20 100644 --- a/src/elements/animation.ts +++ b/src/elements/animation.ts @@ -1,8 +1,10 @@ -import { Identifier, Action, FPElement } from "../interface"; +import { Identifier, Action, FPElement, ElementState } from "../interface"; import { ElementMixin } from "../element"; import { newNode, newButton, Attributes } from "../dom"; - + +type FPAnimationState = [boolean, number]; // playing, speed + export class FPAnimation extends ElementMixin implements FPElement { readonly targets: Array; @@ -43,23 +45,34 @@ export class FPAnimation extends ElementMixin implements FPElement { ); } + get isPlaying(): boolean { + return (this.timeout != null); + } + get value(): undefined { return undefined; } - get state(): any { // TODO - return { - playing: (this.timeout != null), - speed: this.speed - }; + get state(): FPAnimationState { + return [this.isPlaying, this.speed]; } - set state(state: any) { // TODO - this.speed = state.speed; - if (state.playing) { - this.start(); + set state(state: ElementState) { // TODO + const ok = ( + Array.isArray(state) + && state.length === 2 + && typeof state[0] === "boolean" + && typeof state[1] === "number" + ); + if (ok) { + this.speed = state[1]; + if (state[0]) { + this.start(); + } else { + this.stop(); + } } else { - this.stop(); + this.warn(`cannot recover from state ${state}`); // TODO StateError } } diff --git a/src/elements/calendar.ts b/src/elements/calendar.ts index 82ec80d..4b944d7 100644 --- a/src/elements/calendar.ts +++ b/src/elements/calendar.ts @@ -1,10 +1,12 @@ -import { Identifier, FPElement } from "../interface"; +import { Identifier, FPElement, ElementState } from "../interface"; import { ElementMixin } from "../element"; -import { ValueError, ParseError } from "../errors"; +import { ValueError } from "../errors"; import { newNode, Attributes } from "../dom"; import { Value, DateValue } from "../values"; +type FPCalendarState = string; + export class FPCalendar extends ElementMixin implements FPElement { override node: HTMLInputElement; @@ -19,7 +21,7 @@ export class FPCalendar extends ElementMixin implements FPElement { } else if (init instanceof DateValue) { this.resetValue = init.toString("%Y-%m-%d"); } else throw new ValueError( - "cannot initialize calendar with " + init.constructor.name + "cannot initialize calendar with " + init._typeName ); // HTML offers an input type with a nice date selector if (attrs == null) { @@ -49,16 +51,25 @@ export class FPCalendar extends ElementMixin implements FPElement { ); } - get value(): Value { + get value(): DateValue { const value = Value.from(this.node.value); - if (value == null) throw new ParseError( - `unexpected issue parsing ${this.node.value} as value` - ); - return value; + if (value instanceof DateValue) { + return value; + } else { + this.fail(`unexpected issue parsing ${this.node.value} as a DateValue`); // TODO ParseError? + } } - get state(): undefined { - return undefined; // TODO + get state(): FPCalendarState { + return this.node.value; + } + + set state(state: ElementState) { + if (typeof state === "string") { + this.node.value = state; + } else { + this.warn(`cannot recover from state ${state}`); // TODO StateError + } } private get date() { diff --git a/src/elements/items.ts b/src/elements/items.ts index 15fcd43..9ef37ac 100644 --- a/src/elements/items.ts +++ b/src/elements/items.ts @@ -1,4 +1,4 @@ -import { Identifier, Calls, FormatSpec, FPElement, Collection, CollectionEvent } from "../interface"; +import { Identifier, Calls, FormatSpec, FPElement, ElementState, Collection, CollectionEvent } from "../interface"; import { ElementError } from "../errors"; import { ElementMixin } from "../element"; import { newNode, newButton, Attributes } from "../dom"; @@ -57,6 +57,7 @@ export function selectFrom(node: HTMLElement): FPItems { +type FPItemsState = number; class FPItems extends ElementMixin implements FPElement { // Base class for control elements wrapping a Items instance. Most @@ -103,18 +104,21 @@ class FPItems extends ElementMixin implements FPElement { assertFinite(): void { if (!this.items.isFinite) { this.fail("list of items is not finite"); - return; } } // (De-)Serialization - get state(): number { + get state(): FPItemsState { return this.items.index; } - set state(state: number) { - this.items.index = state; + set state(state: ElementState) { + if (typeof state === "number") { + this.items.index = state; + } else { + this.warn(`cannot recover from state ${state}`); // TODO StateError + }; } // Actions diff --git a/src/elements/state.ts b/src/elements/state.ts index bcf87ad..cdecb54 100644 --- a/src/elements/state.ts +++ b/src/elements/state.ts @@ -1,11 +1,14 @@ -import { Identifier, FPElement } from "../interface"; +import { Identifier, FPElement, ManagerState } from "../interface"; import { ElementMixin } from "../element"; import { Attributes } from "../dom"; -class FPState extends FPElement { +export class FPState extends ElementMixin implements FPElement { - constructor(id, useURL) { + private readonly useURL: boolean; + private savedState: null | ManagerState; + + constructor(id: Identifier | undefined, useURL: boolean) { super(id); this.useURL = useURL; this.savedState = null; @@ -13,27 +16,35 @@ class FPState extends FPElement { this.actions.add("restore"); } - initialize() { + override initialize() { if (this.useURL === true) { this.flottplot.urlstate = true; } } - save() { + get value(): undefined { + return undefined; + } + + get state(): undefined { + return undefined; + } + + save(): void { this.savedState = this.flottplot.state; } - restore() { + restore(): void { if (this.savedState != null) { this.flottplot.state = this.savedState; } } - static from(node) { - const attrs = dom.Attributes.from(node); + static from(node: HTMLElement): FPState { + const attrs = Attributes.from(node); return new FPState( attrs.id, - attrs.pop("url", false, "BOOL") + attrs.getAsBool("url", "false", true) ); } diff --git a/src/elements/text.ts b/src/elements/text.ts index d234958..ce42e1d 100644 --- a/src/elements/text.ts +++ b/src/elements/text.ts @@ -1,24 +1,35 @@ -import { Identifier, FPElement } from "../interface"; +import { Identifier, Substitution, FPElement } from "../interface"; import { ElementMixin } from "../element"; -import { Attributes } from "../dom"; +import { newNode } from "../dom"; -class FPText extends FPElement { +export class FPText extends ElementMixin implements FPElement { - constructor(id, text) { + override node: HTMLSpanElement; + private readonly text: string; + + constructor(id: Identifier | undefined, text: string) { super(id); - this.node = dom.newNode("span", { "id": id }); + this.node = newNode("span", { "id": id }); this.text = text; this.setDependenciesFrom(text); } - update(subst) { - let text = this.substitute(this.text, subst); - this.node.textContent = text; + override update(subst: Substitution): void { + this.node.textContent = this.substitute(this.text, subst); + } + + get value(): undefined { + return undefined; + } + + get state(): undefined { + return undefined; } - static from(node) { - return new FPText(node.id, node.textContent); + static from(node: HTMLElement): FPText { + const text = node.textContent; + return new FPText(node.id, (text == null) ? "" : text); } } diff --git a/src/errors.ts b/src/errors.ts index 260a2ef..6fab60d 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,7 +1,14 @@ -export class FlottplotError extends Error {} +export class FlottplotError extends Error { + + constructor(message?: string) { + super(`Flottplot Error: ${message}`); + } + +} export class ParseError extends FlottplotError {} export class FormatError extends FlottplotError {} +export class StateError extends FlottplotError {} export class ValueError extends FlottplotError {} export class ItemsError extends FlottplotError {} diff --git a/src/expression.ts b/src/expression.ts index d9e0b4b..74a9bda 100644 --- a/src/expression.ts +++ b/src/expression.ts @@ -272,7 +272,7 @@ export class Expr implements Expression { // Both forward and backward operators either don't exist or return // undefined. The operation is not possible. throw new ValueError( - "operator '" + this.op.method + "' not defined for " + args.map(_ => _.constructor.name).join(" and ") + "operator '" + this.op.method + "' not defined for " + args.map(_ => _._typeName).join(" and ") ); } diff --git a/src/interface.ts b/src/interface.ts index 2fe5ba3..03c6210 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -1,5 +1,6 @@ import { Value } from "./values"; import { Fullscreen } from "./dom"; +import { FlottplotError } from "./errors"; // Identifiers must be usable as Map keys export type Identifier = string; @@ -15,7 +16,7 @@ export type Substitution = Map; // Formatting specification for values -export type FormatSpec = string; +export type FormatSpec = string | undefined; export interface Expression { toString(): string; @@ -42,8 +43,8 @@ export enum CollectionEvent { } // ... -export type ElementState = any; // TODO -export type ManagerState = any; // TODO +export type ElementState = unknown; +export type ManagerState = Record; export interface Manager { // ... @@ -60,6 +61,7 @@ export interface Manager { state: ManagerState; overlay: any; // TODO fullscreen: Fullscreen; // TODO + urlstate: boolean; } export interface FPElement { @@ -79,7 +81,7 @@ export interface FPElement { update(substitution?: Substitution): void; invoke(action: Action): void; // TODO: args notify(): void; - fail(message: string): void; - failWith(error: Error): void; + warn(message: string | Error): FlottplotError; + fail(message: string | Error): never; } diff --git a/src/items.ts b/src/items.ts index 25684c8..2e36022 100644 --- a/src/items.ts +++ b/src/items.ts @@ -133,6 +133,9 @@ export class RangeItems extends Items implements Collection { _factor: Value; _selected: number; + readonly valueType: any; + readonly valueTypeName: string; + indexMin: number; indexMax: number; @@ -156,16 +159,19 @@ export class RangeItems extends Items implements Collection { "range requires specification of at least one of init, min or max" ); // The type of this._offset determines the type of all values this - // range produces. This type is accessible via the valueType property - // for instanceof comparisons. First, verify that min and max have the - // appropriate type. init does not need to be checked (if it is not - // null, it is this._offset). step is allowed to have a different type - // (e.g., the step between dates is a datedelta). + // range produces. Make this type accessible via the valueType property + // for instanceof comparisons. + this.valueType = this._offset.constructor; + this.valueTypeName = this._offset._typeName; + // Verify that min and max have the appropriate type. init does not + // need to be checked (if it is not null, it is this._offset). step is + // allowed to have a different type (e.g., the step between dates is + // a datedelta). if (!(min == null || min instanceof this.valueType)) throw new ItemsError( - "min is a " + min.constructor.name + " but range expects " + this.valueType.name + "min is a " + min._typeName + " value but range expects " + this.valueTypeName ); if (!(max == null || max instanceof this.valueType)) throw new ItemsError( - "max is a " + max.constructor.name + " but range expects " + this.valueType.name + "max is a " + max._typeName + " value but range expects " + this.valueTypeName ); // TODO if (step == null) throw new ItemsError( @@ -188,10 +194,6 @@ export class RangeItems extends Items implements Collection { this.value = this._offset; } - get valueType() { - return this._offset.constructor; - } - _genValue(index: number): Value { // TODO: attach index? // Because this is not going through proper Expression evaluation, the @@ -202,12 +204,12 @@ export class RangeItems extends Items implements Collection { } _genIndex(value: unknown): number { - value = Value.from(value); - if (value instanceof this.valueType) { + const v = Value.from(value); + if (v instanceof this.valueType) { // Clip value into range - return Math.round((value as any)._sub(this._offset)._div(this._factor)._value); // TODO any + return Math.round((v as any)._sub(this._offset)._div(this._factor)._value); // TODO any } else throw new ItemsError( - `range expects ${this.valueType.name} but received ${value!.constructor.name}` + `range expects ${this.valueTypeName} value but received ${v!._typeName}` ); } diff --git a/src/manager.ts b/src/manager.ts index 9d7e69b..763c594 100644 --- a/src/manager.ts +++ b/src/manager.ts @@ -1,4 +1,4 @@ -import { Identifier, Action, Calls, Substitution, Pattern, FPElement, Manager, ManagerState } from "./interface"; +import { Identifier, Action, Calls, Substitution, FPElement, Manager, ManagerState } from "./interface"; import { ElementError, FlottplotError } from "./errors"; import { UpdateGraph } from "./graph"; import * as dom from "./dom"; @@ -14,6 +14,12 @@ type TagRegistration = { isRecursive: boolean; }; + +function containsState(obj: unknown): obj is ManagerState { + return (typeof obj === "object" && !Array.isArray(obj) && obj !== null); +} + + export class Flottplot implements Manager { _elements: Map; @@ -49,8 +55,8 @@ export class Flottplot implements Manager { const duplicate = this._elements.get(element.id); // Make sure id doesn't already exist in the collection if (duplicate != null) { - element.fail("duplicate id"); - duplicate.fail("duplicate id"); + element.warn("duplicate id"); + duplicate.warn("duplicate id"); } // Add element to collection this._elements.set(element.id, element); @@ -96,7 +102,6 @@ export class Flottplot implements Manager { node.replaceWith(dom.newNode("div", { "style": "border:3px solid #F00;background-color:#FCC;padding:3px;", }, [ - dom.newNode("b", {}, [error.constructor.name, ": "]), error.message ])); // Additionally log the error on the console @@ -129,7 +134,7 @@ export class Flottplot implements Manager { try { element.initialize(this._substitutionFor(element)); } catch (error) { - element.failWith(error); + element.warn(error); console.error(error); } } @@ -170,7 +175,7 @@ export class Flottplot implements Manager { values.set(dep, dep_element.value); } // Evaluate expressions in all patterns with values and format - const out: Map = new Map(); + const out: Substitution = new Map(); for (const [pattern, [expression, format]] of element.patterns) { out.set(pattern, expression._eval(values).toString(format)); } @@ -225,17 +230,18 @@ export class Flottplot implements Manager { return out; } - set state(state: ManagerState) { - for (const id of this._graph.orderedNodes) { - if (!state.hasOwnProperty(id)) { - continue; + set state(state: unknown) { + if (!containsState(state)) throw new FlottplotError( + "TODO 2389" // TODO + ); + for (const id in state) { + const element = this._elements.get(id); + if (element != null) { + element.state = state[id]; + element.update(this._substitutionFor(element)); + element.notify(); + // TODO pause changing of hash during update and notify? } - // Skip null check since id comes from graph - const element: FPElement = this._elements.get(id)!; - element.state = state[id]; - element.update(this._substitutionFor(element)); - element.notify(); - // TODO pause changing of hash during update and notify? } } diff --git a/src/values.ts b/src/values.ts index 31487cb..e452cb6 100644 --- a/src/values.ts +++ b/src/values.ts @@ -8,6 +8,7 @@ export abstract class Value { _TEXT?: string; abstract toString(spec?: FormatSpec): string; + abstract readonly _typeName: string; // Every value gets a user-accessible TEXT attribute, which should be set // to the raw text input used to generate the value. If the value wasn't @@ -58,7 +59,7 @@ export abstract class Value { export class TextValue extends Value implements Expression { - _value: string; + readonly _value: string; constructor(value: unknown) { super(); @@ -68,7 +69,11 @@ export class TextValue extends Value implements Expression { this._value = value.toString(); } - toString(spec?: string): string { + get _typeName(): string { + return "Text"; + } + + toString(spec?: FormatSpec): string { // No format specification given, return text as-is if (spec == null) { return this._value; @@ -113,7 +118,7 @@ export class TextValue extends Value implements Expression { export class NumberValue extends Value implements Expression { - _value: number; + readonly _value: number; constructor(value: unknown) { super(); @@ -131,10 +136,17 @@ export class NumberValue extends Value implements Expression { } } - toString(spec?: string): string { + get _typeName(): string { + return "Number"; + } + + toString(spec?: FormatSpec): string { + // No format specification given: return value as it was entered by the + // user or fall back to default representation if (spec == null) { - return this._value.toString(); + return (this._TEXT != null) ? this._TEXT : this._value.toString(); } + // Try to apply format specification const aspy = pyformat(this._value, spec); if (aspy != null) { return aspy; @@ -256,7 +268,7 @@ function asDate(value: string): Date { export class DateValue extends Value implements Expression { - _value: Date; + readonly _value: Date; YEAR: NumberValue; MONTH: NumberValue; @@ -284,8 +296,21 @@ export class DateValue extends Value implements Expression { this.SECOND = new NumberValue(this._value.getUTCSeconds()); } - toString(spec?: string): string { - const aspy = pystrftime(this._value, (spec != null) ? spec : "%Y-%m-%d %H:%M:%S"); + get _typeName(): string { + return "Date"; + } + + toString(spec?: FormatSpec): string { + // If no format specification is given try to return original user + // input or supply the default specification: YYYY-mm-dd HH:MM:SS + if (spec == null) { + if (this._TEXT != null) { + return this._TEXT; + } else { + spec = "%Y-%m-%d %H:%M:%S"; + } + } + const aspy = pystrftime(this._value, spec); if (aspy != null) { return aspy; } @@ -326,7 +351,7 @@ export class DateValue extends Value implements Expression { export class DateDeltaValue extends Value implements Expression { - _value: number; + readonly _value: number; SIGN: NumberValue; DAYS: NumberValue; @@ -377,19 +402,30 @@ export class DateDeltaValue extends Value implements Expression { this.TOTAL_SECONDS = new NumberValue(this._value); } - toString(spec?: string): string { - // User should use number formatting options of attributes instead - if (spec != null) throw new FormatError( + get _typeName(): string { + return "DateDelta"; + } + + toString(spec?: FormatSpec): string { + // If no format specification is given try to return original user + // input or fall back to a default format: ±DDd HH:MM:SS + if (spec == null) { + if (this._TEXT != null) { + return this._TEXT; + } else { + return ( + (this.SIGN._value < 0 ? "-" : "+") + + this.DAYS.toString() + "d " + + this.HOURS.toString("0>2") + ":" + + this.MINUTES.toString("0>2") + ":" + + this.SECONDS.toString("0>2") + ); + } + } + // For now, users should use attributes and number formatting + throw new FormatError( "invalid specification '" + spec + "' for date delta value '" + this.toString() + "'" ); - // Return in format '±DDd HH:MM:SS' - return ( - (this.SIGN._value < 0 ? "-" : "+") - + this.DAYS.toString() + "d " - + this.HOURS.toString("0>2") + ":" - + this.MINUTES.toString("0>2") + ":" - + this.SECONDS.toString("0>2") - ); } _pos(): DateDeltaValue { @@ -444,7 +480,7 @@ export class DateDeltaValue extends Value implements Expression { // Attribute names are special objects export class AttributeValue extends Value implements Expression { - _name: string; + readonly _name: string; constructor(name: string) { super(); @@ -454,6 +490,10 @@ export class AttributeValue extends Value implements Expression { ); } + get _typeName(): string { + return "Attribute"; + } + toString(spec?: string): string { if (spec != null) throw new FormatError("sdjfjasdf"); // TODO return this._name; diff --git a/test/test_values.js b/test/test_values.js index 6ee0a87..108c37b 100644 --- a/test/test_values.js +++ b/test/test_values.js @@ -120,11 +120,17 @@ describe("Value formatting", function () { }); it("DateDeltaValue default format", function() { - let pos = new DateDeltaValue("+5m"); + const pos = new DateDeltaValue("+5m"); + assert.strictEqual(pos.toString(), "+5m"); + pos._TEXT = null; // TODO create a proper mechanism to obtain default formatting assert.strictEqual(pos.toString(), "+0d 00:05:00"); - let neg = new DateDeltaValue("-251h"); + const neg = new DateDeltaValue("-251h"); + assert.strictEqual(neg.toString(), "-251h"); + neg._TEXT = null; // TODO create a proper mechanism to obtain default formatting assert.strictEqual(neg.toString(), "-10d 11:00:00"); - let zero = new DateDeltaValue("0d"); + const zero = new DateDeltaValue("0d"); + assert.strictEqual(zero.toString(), "0d"); + zero._TEXT = null; // TODO create a proper mechanism to obtain default formatting assert.strictEqual(zero.toString(), "+0d 00:00:00"); }); @@ -161,6 +167,11 @@ describe("Value operators", function () { ["-3 § foo ", "ijk"], [" foo § 3", "abc"], [" foo § -3", "abcdefgh"], + [" 3 § foo § 3", "def"], + ["-3 § foo § 1", "i"], + [" 3 § foo § -2", "defghi"], + ["-3 § foo § -2", "i"], + ["-3 § foo § -4", ""] ], [ ["foo", new TextValue("abcdefghijk")] ])); diff --git a/util/generate_dependencies.py b/util/generate_dependencies.py new file mode 100644 index 0000000..e448b37 --- /dev/null +++ b/util/generate_dependencies.py @@ -0,0 +1,46 @@ +import argparse +from pathlib import Path +import os +import re + +EXT = ".ts" +IMPORT = re.compile(r"^import .* from \"(.+)\";?$"); + + +def get_deps(path): + out = set() + with open(path, "r") as f: + for line in f: + match = IMPORT.match(line); + if match is not None: + out.add(Path(match.group(1) + EXT)) + return out + +def get_deps_recursive(path, checked=None): + path = Path(path).resolve() + if checked is None: + checked = set() + checked.add(path) + for dep in get_deps(path): + dep = (path.parent / dep).resolve() + if dep not in checked: + get_deps_recursive(dep, checked) + return checked + + +parser = argparse.ArgumentParser() +parser.add_argument("entry") + +if __name__ == "__main__": + args = parser.parse_args() + + entry = Path(args.entry).resolve() + + deps = get_deps_recursive(entry) + deps.remove(entry) + deps = [os.path.relpath(dep) for dep in sorted(deps)] + deps = " ".join(deps) + + print(f"{os.path.relpath(entry)}: {deps}") + print(f"\ttouch $@") +