@citolab/tspci 2.12.1 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,45 +1,58 @@
1
- import { useStore, IStore, Action } from "@citolab/preact-store";
2
- import { ActionType, StateModel } from "./store";
1
+ import type { PciProperties } from "@citolab/tspci";
2
+ import { IStore, useStore } from "@citolab/preact-store";
3
+ import { useLayoutEffect, useRef, useState } from "preact/hooks";
3
4
  import configProps from "./config.json";
4
- import { useState } from "preact/hooks";
5
+ import { Pie } from "./pie";
6
+ import { Actions, StateModel } from "./store";
7
+ // Images are bundled too: `import logo from "./assets/logo.png"` gives a data URL for <img src={logo} />.
5
8
 
6
- import procenten from "./assets/procenten.png"; // image types are bundled inside the js
9
+ type PropTypes = PciProperties<typeof configProps>;
7
10
 
8
- type PropTypes = typeof configProps;
11
+ type Props = {
12
+ config: PropTypes;
13
+ store: IStore<StateModel, Actions>;
14
+ readOnly: boolean; // review, solution, closed: show the answer, but do not change it
15
+ };
9
16
 
10
- const Interaction = ({ config, dom, store }: { config: PropTypes; dom: Document | ShadowRoot, store: IStore<StateModel> }) => {
11
- const state = useStore(store);
12
- const [eventHandled, setEventHandled] = useState<boolean>(false);
13
-
14
- const handleInput = (e: Event) => {
15
- if (!eventHandled) {
16
- const input = e.target as HTMLInputElement;
17
- store.dispatch<{ input: number }>({ type: "SET_INPUT", payload: { input: +input.value } });
18
- setEventHandled(true);
19
- }
20
- };
17
+ /** The interaction itself: Tailwind classes only, styles.css just loads Tailwind (into the shadow root). */
18
+ const Interaction = ({ config, store, readOnly }: Props) => {
19
+ const { value } = useStore(store); // re-renders when the store changes
20
+ const input = useRef<HTMLInputElement>(null);
21
+ const [invalid, setInvalid] = useState(false);
21
22
 
22
- return <div className="pci-container">
23
- <h1 className="title">{config.title}</h1>
24
- <div className="body">
25
- <img className="body-image" width={config.width} height={+config.height} src={procenten} alt="Percentages visual" />
26
- </div>
27
- <div className="interaction">
28
- <label className="prompt" htmlFor="percentage-input">{config.prompt}</label>
29
- <div className="answer-row">
30
- <input className="answer-input"
31
- id="percentage-input"
23
+ // aria-invalid tells assistive technology, and the aria-invalid: classes show it with a red outline.
24
+ useLayoutEffect(() => {
25
+ setInvalid(value !== "" && !input.current.checkValidity());
26
+ }, [value]);
27
+
28
+ return (
29
+ <div className="mx-auto flex w-fit max-w-full flex-col items-center gap-4 rounded-2xl border border-slate-200 bg-linear-to-b from-white to-slate-50 px-7 py-6 font-sans text-slate-800 shadow-lg">
30
+ <h2 className="text-xl font-semibold tracking-tight">{config.title}</h2>
31
+ <Pie config={config} />
32
+ {/* htmlFor ties the question to the input: a screen reader reads it, a click on it focuses the input */}
33
+ <label className="max-w-[32ch] cursor-pointer text-center text-slate-600" htmlFor="answer">
34
+ {config.question}
35
+ </label>
36
+ <div className="flex items-center gap-1.5 text-lg font-semibold">
37
+ <input
38
+ ref={input}
39
+ id="answer"
32
40
  type="number"
33
- value={state.input}
34
- onInput={handleInput}
35
- onPaste={() => setEventHandled(false)}
36
- onKeyDown={() => setEventHandled(false)}
37
- min="0" max="100" />
38
- <span className="answer-suffix">%</span>
41
+ inputMode="numeric"
42
+ min={0}
43
+ max={100}
44
+ step={1}
45
+ autoComplete="off"
46
+ value={value}
47
+ disabled={readOnly}
48
+ aria-invalid={invalid}
49
+ onInput={(e) => store.dispatch({ type: "SET_VALUE", payload: { value: e.currentTarget.value } })}
50
+ className="w-20 rounded-lg border-[1.5px] border-slate-300 bg-white px-2.5 py-2 text-right outline-none transition [appearance:textfield] focus-visible:border-blue-500 focus-visible:ring-3 focus-visible:ring-blue-500/30 aria-invalid:border-rose-600 aria-invalid:ring-3 aria-invalid:ring-rose-600/20 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
51
+ />
52
+ <span>%</span>
39
53
  </div>
40
54
  </div>
41
- </div>;
55
+ );
42
56
  };
43
57
 
44
-
45
58
  export default Interaction;
@@ -0,0 +1,46 @@
1
+ import type { PciProperties } from "@citolab/tspci";
2
+ import configProps from "./config.json";
3
+
4
+ type PropTypes = PciProperties<typeof configProps>;
5
+
6
+ /** Settings arrive as strings (they are data- attributes) and an author may type anything: parse and clamp them. */
7
+ const toInt = (value: string, fallback: number, min: number, max: number) => {
8
+ const n = Number.parseInt(value, 10);
9
+ return Number.isNaN(n) ? fallback : Math.min(max, Math.max(min, n));
10
+ };
11
+
12
+ /** A circle of `segments` equal slices, the first `colored` ones filled, clockwise from the top. */
13
+ export const Pie = ({ config }: { config: PropTypes }) => {
14
+ // the limits match the min/max in config.json
15
+ const segments = toInt(config.segments, 10, 2, 24);
16
+ const colored = toInt(config.colored, 0, 0, segments);
17
+ const size = toInt(config.size, 200, 100, 400);
18
+ const c = size / 2; // centre
19
+ const r = c - 4; // leave room for the outline
20
+ const point = (i: number) => {
21
+ const angle = -Math.PI / 2 + (2 * Math.PI * i) / segments;
22
+ return `${(c + r * Math.cos(angle)).toFixed(2)} ${(c + r * Math.sin(angle)).toFixed(2)}`;
23
+ };
24
+
25
+ // role="img" + aria-label: a screen reader reads one description instead of a list of paths.
26
+ // Translate the label when your items are not in English.
27
+ return (
28
+ <svg
29
+ className="h-auto max-w-full drop-shadow-md"
30
+ viewBox={`0 0 ${size} ${size}`}
31
+ width={size}
32
+ height={size}
33
+ role="img"
34
+ aria-label={`A circle in ${segments} equal slices, ${colored} of them coloured`}
35
+ >
36
+ {Array.from({ length: segments }, (_, i) => (
37
+ <path
38
+ key={i}
39
+ className={i < colored ? "stroke-white stroke-2" : "fill-slate-200 stroke-white stroke-2"}
40
+ fill={i < colored ? config.color || "#3b82f6" : undefined}
41
+ d={`M ${c} ${c} L ${point(i)} A ${r} ${r} 0 0 1 ${point(i + 1)} Z`}
42
+ />
43
+ ))}
44
+ </svg>
45
+ );
46
+ };
@@ -1,19 +1,17 @@
1
1
  import { Store } from "@citolab/preact-store";
2
2
 
3
- export type ActionType = "SET_INPUT" | "RESTORE_STATE";
4
- export type StateModel = { input: number | undefined };
3
+ /**
4
+ * The state of the PCI. Every change goes through an action (store.dispatch), and the store keeps the list of actions:
5
+ * getState() saves both, so a session can be restored, and even replayed (store.replay), action by action.
6
+ */
7
+ export type StateModel = { value: string }; // the text in the input, also when it is not a number yet
8
+ /** The actions, as { type: payload }: dispatch and addReducer only accept these. */
9
+ export type Actions = { SET_VALUE: { value: string } };
5
10
 
6
- export const initStore = (
7
- initialState: StateModel,
8
- restoreData?: {
9
- state: StateModel;
10
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
- actions: { type: string; payload: any; timestamp?: number }[];
12
- }
13
- ) => {
14
- const store = new Store<StateModel>(initialState, restoreData);
15
- store.addReducer<{ input: number | undefined }>("SET_INPUT", (state, { input }) => {
16
- return { ...state, input };
17
- });
11
+ export const initialState: StateModel = { value: "" };
12
+
13
+ export const initStore = (state: StateModel = initialState) => {
14
+ const store = new Store<StateModel, Actions>(state);
15
+ store.addReducer("SET_VALUE", (current, { value }) => ({ ...current, value }));
18
16
  return store;
19
17
  };
@@ -1,30 +1,7 @@
1
+ /*
2
+ * Tailwind 4, compiled into the bundle and added to the PCI's shadow root (index.tsx): its classes style only this PCI.
3
+ * The components use the utility classes directly; @source tells Tailwind where to find them.
4
+ */
1
5
  @import "tailwindcss";
2
6
  @source "./**/*.{html,js,ts,jsx,tsx}";
3
7
  @source "../*.xml";
4
- .pci-container {
5
- @apply mx-auto flex w-full max-w-xl flex-col items-center gap-5 rounded-2xl border border-slate-200 bg-white p-6 shadow-lg;
6
- }
7
- .title {
8
- @apply text-center text-2xl font-semibold tracking-tight text-slate-800;
9
- }
10
- .body {
11
- @apply flex w-full justify-center;
12
- }
13
- .body-image {
14
- @apply rounded-xl border border-slate-200 bg-slate-50 p-2;
15
- }
16
- .interaction {
17
- @apply w-full rounded-xl border border-slate-200 bg-slate-50 p-4;
18
- }
19
- .prompt {
20
- @apply mb-2 block text-sm font-medium text-slate-700;
21
- }
22
- .answer-row {
23
- @apply flex items-center gap-2;
24
- }
25
- .answer-input {
26
- @apply w-28 rounded-lg border border-slate-300 bg-white px-3 py-2 text-lg font-medium text-slate-900 shadow-sm outline-none transition focus:border-amber-500 focus:ring-2 focus:ring-amber-200;
27
- }
28
- .answer-suffix {
29
- @apply text-sm font-semibold uppercase tracking-wide text-slate-500;
30
- }
@@ -1,191 +1,187 @@
1
+ /*
2
+ * A Portable Custom Interaction (PCI) in plain TypeScript.
3
+ *
4
+ * The delivery engine (the player) loads this bundle, which registers a factory with qtiCustomInteractionContext.
5
+ * For every interaction in an item the engine calls factory.getInstance(dom, config, state); the instance renders
6
+ * into `dom` and reports its answer through getResponse().
7
+ *
8
+ * The demo: "what percentage of the circle is coloured?". The circle is drawn from the settings in src/config.json
9
+ * (pie.ts); the answer is a whole number, reported as { base: { integer: 30 } } (see config.tspci.response in
10
+ * package.json and AUTHORING.md).
11
+ */
1
12
  import {
2
13
  ConfigProperties,
3
14
  IMSpci,
4
15
  IMSpciFactory,
16
+ PciProperties,
5
17
  QtiInteractionChangedDetail,
6
18
  QtiVariableJSON,
7
19
  } from "@citolab/tspci";
8
- import * as ctx from "qtiCustomInteractionContext";
9
- import configProps from "./config.json";
10
- import Interaction from "./interaction"; // import and bundle this interaction file
11
-
12
- import style from "./styles.css"; // import and bundle this style file ( you can use tailwind and nested css )
13
- import procenten from "./assets/procenten.png"; // image types are bundled inside the js
14
- // Configuration
15
- type PropTypes = typeof configProps;
16
-
17
- type PciState = {
18
- value: string;
19
- touched: boolean;
20
- customValidity?: string;
21
- };
20
+ import * as ctx from "qtiCustomInteractionContext"; // provided by the delivery engine at runtime
21
+ import configProps from "./config.json"; // the settings; the build passes only their defaults (AUTHORING.md)
22
+ import style from "./styles.css"; // bundled as a string, added to the shadow root below
23
+ import { escapeHtml, pieSvg } from "./pie";
24
+ // Images are bundled too: `import logo from "./assets/logo.png"` gives a data URL for <img src>.
25
+
26
+ // Like every data- attribute, each setting arrives as a string.
27
+ type PropTypes = PciProperties<typeof configProps>;
28
+
29
+ /** What getState() saves and the constructor restores: the text in the input, also when it is not a number yet. */
30
+ type PciState = { value: string };
31
+
32
+ /** The response for the text in the input: a whole number, or undefined while there is no answer. */
33
+ function toResponse(value: string): QtiVariableJSON | undefined {
34
+ const trimmed = value.trim();
35
+ if (trimmed === "") return undefined; // nothing answered, nothing reported
36
+ const n = Number(trimmed);
37
+ // One answer, one response: "30", "030" and "30.0" all report 30.
38
+ return Number.isFinite(n) ? { base: { integer: Math.round(n) } } : undefined;
39
+ }
40
+
41
+ /** The input text for a response (setResponse, boundTo) or a correct response (status "solution"). */
42
+ function fromResponse(value: unknown): string {
43
+ if (Array.isArray(value)) return fromResponse(value[0]);
44
+ if (value && typeof value === "object") {
45
+ const base = (value as QtiVariableJSON).base;
46
+ return fromResponse(base?.integer ?? base?.float ?? base?.string);
47
+ }
48
+ return value === undefined || value === null ? "" : String(value);
49
+ }
22
50
 
23
51
  class PciInstance implements IMSpci<PropTypes> {
24
52
  typeIdentifier = "###___PCI_NAME___###";
25
53
  private config: ConfigProperties<PropTypes>;
26
54
  private dom: HTMLElement;
27
55
  private shadowdom: ShadowRoot;
28
- private state: PciState;
29
- private onChange: (e: Event) => void;
56
+ private state: PciState = { value: "" };
57
+ private customValidity = "";
30
58
 
31
59
  constructor(dom: HTMLElement, config: ConfigProperties<PropTypes>, stateString?: string) {
32
60
  if (!dom) throw new Error("No DOM Element provided");
33
- config.properties = { ...configProps, ...config.properties };
61
+ // The item's data- attributes override the defaults from config.json.
62
+ config.properties = { ...(configProps as unknown as PropTypes), ...config.properties };
34
63
  this.dom = dom;
35
64
  this.config = config;
65
+ // A closed shadow root keeps the item's CSS out and ours in.
36
66
  this.shadowdom = dom.attachShadow({ mode: "closed" });
37
67
 
38
- this.state = { value: "", touched: false };
68
+ // 1. The state from a previous session (whatever getState() returned).
39
69
  if (stateString) {
40
70
  try {
41
71
  const parsed = JSON.parse(stateString) as Partial<PciState>;
42
- this.state = {
43
- value: typeof parsed.value === "string" ? parsed.value : "",
44
- touched: parsed.touched === true,
45
- customValidity: typeof parsed.customValidity === "string" ? parsed.customValidity : undefined,
46
- };
72
+ this.state = { value: typeof parsed?.value === "string" ? parsed.value : "" };
47
73
  } catch {
48
- this.state = { value: typeof stateString === "string" ? stateString : "", touched: false };
74
+ // not ours: start empty
49
75
  }
50
76
  }
77
+ // 2. A response the engine binds to the interaction (boundTo) wins over the state.
78
+ const boundTo = config.boundTo ?? {};
79
+ const bound = boundTo[config.responseIdentifier] ?? (Object.keys(boundTo).length === 1 ? Object.values(boundTo)[0] : undefined);
80
+ if (bound) this.state = { value: fromResponse(bound) };
51
81
 
52
- const boundResponse =
53
- this.config.boundTo?.[this.config.responseIdentifier] ??
54
- (this.config.boundTo && Object.keys(this.config.boundTo).length === 1
55
- ? this.config.boundTo[Object.keys(this.config.boundTo)[0]]
56
- : undefined);
57
- if (boundResponse && (boundResponse.base || boundResponse.list || boundResponse.record)) {
58
- this.setResponse?.(boundResponse);
59
- }
82
+ this.render();
83
+ // 3. Status "solution": show the correct response of the item instead (AUTHORING.md, "Correct answers").
84
+ if (config.status === "solution") this.showCorrectResponse();
60
85
 
61
- this.onChange = (e: Event) => {
62
- const target = e.target as HTMLInputElement | null;
63
- if (!target) return;
64
- const nextValue = target.value ?? "";
65
- if (nextValue === this.state.value && this.state.touched) return;
66
- this.state = { ...this.state, value: nextValue, touched: true };
67
- this.dispatchInteractionChanged();
68
- };
86
+ config.onready?.(this, this.getState());
87
+ }
69
88
 
70
- this.render();
89
+ /** Read-only when the candidate can no longer answer. */
90
+ private get readOnly() {
91
+ return ["review", "solution", "closed"].includes(this.config.status ?? "interacting");
92
+ }
71
93
 
72
- if (this.config.onready) {
73
- this.config.onready(this, this.getState());
74
- }
94
+ private get input() {
95
+ return this.shadowdom.querySelector<HTMLInputElement>("input");
75
96
  }
76
97
 
77
- private getInputEl(): HTMLInputElement | null {
78
- return this.shadowdom.querySelector("input[type='number']");
98
+ private render() {
99
+ const p = this.config.properties;
100
+ // <label for> ties the question to the input, so a screen reader reads it and a click on it focuses the input.
101
+ this.shadowdom.innerHTML = `
102
+ <style>${style}</style>
103
+ <div class="pci">
104
+ <h2 class="title">${escapeHtml(p.title)}</h2>
105
+ ${pieSvg(p)}
106
+ <label class="question" for="answer">${escapeHtml(p.question)}</label>
107
+ <div class="answer">
108
+ <input id="answer" type="number" inputmode="numeric" min="0" max="100" step="1" autocomplete="off"
109
+ value="${escapeHtml(this.state.value)}" ${this.readOnly ? "disabled" : ""} />
110
+ <span class="unit">%</span>
111
+ </div>
112
+ </div>`;
113
+
114
+ const input = this.input;
115
+ input.setCustomValidity(this.customValidity);
116
+ this.markValidity();
117
+ input.addEventListener("input", this.onInput);
79
118
  }
80
119
 
81
- private dispatchInteractionChanged() {
120
+ private onInput = () => {
121
+ this.state = { value: this.input.value };
122
+ this.markValidity();
123
+ // Tell the engine the response changed; composed lets the event cross the shadow root.
82
124
  const detail: QtiInteractionChangedDetail = {
83
125
  interaction: this,
84
126
  responseIdentifier: this.config.responseIdentifier,
85
- valid: this.checkValidity?.() ?? true,
127
+ valid: this.checkValidity(),
86
128
  value: this.getResponse(),
87
129
  };
88
- const event = new CustomEvent("qti-interaction-changed", {
89
- bubbles: true,
90
- composed: true,
91
- cancelable: true,
92
- detail,
93
- });
94
- this.dom.dispatchEvent(event);
95
- }
130
+ this.dom.dispatchEvent(new CustomEvent("qti-interaction-changed", { bubbles: true, composed: true, detail }));
131
+ };
96
132
 
97
- private render() {
98
- this.shadowdom.innerHTML = `<div class="pci-container">
99
- <h1>${this.config.properties.title}</h1>
100
- <div class="body">
101
- <img width="${+this.config.properties.width}" height="${+this.config
102
- .properties.height}" src="${procenten}" />
103
- </div>
104
- <div class="interaction">
105
- <label>${this.config.properties.prompt}</label>
106
- <input type="number" value="${this.state.value}" min="0" max="100">%
107
- </div>
108
- </div>`;
109
-
110
- const css = document.createElement("style");
111
- css.innerHTML = style;
112
- this.shadowdom.appendChild(css);
113
-
114
- const input = this.getInputEl();
115
- if (input) {
116
- if (this.state.customValidity) input.setCustomValidity(this.state.customValidity);
117
- input.addEventListener("input", this.onChange);
118
- input.addEventListener("change", this.onChange);
119
- }
133
+ /** aria-invalid tells assistive technology, and styles.css shows it with a red outline. */
134
+ private markValidity() {
135
+ const input = this.input;
136
+ input.setAttribute("aria-invalid", String(input.value !== "" && !input.checkValidity()));
120
137
  }
121
138
 
122
- setResponse = (response: QtiVariableJSON) => {
123
- const next =
124
- typeof response?.base?.integer === "number"
125
- ? String(response.base.integer)
126
- : typeof response?.base?.string === "string"
127
- ? response.base.string
128
- : "";
129
- this.state = { ...this.state, value: next, touched: true };
130
- const input = this.getInputEl();
131
- if (input) input.value = next;
139
+ /** Show the correct response the item declares (status "solution"); without one, keep what is there. */
140
+ showCorrectResponse = () => {
141
+ const correct = this.config.responseDeclaration?.correctResponse?.value;
142
+ if (correct === undefined) return;
143
+ this.state = { value: fromResponse(correct) };
144
+ this.render();
132
145
  };
133
146
 
134
- getResponse = () => {
135
- if (!this.state.touched) return undefined;
136
- const trimmed = (this.state.value ?? "").toString().trim();
137
- if (!trimmed) return undefined;
138
- const asNumber = Number(trimmed);
139
- if (!Number.isFinite(asNumber)) return undefined;
140
- return { base: { integer: Math.trunc(asNumber) } } as QtiVariableJSON;
141
- };
147
+ // ---- the IMS PCI API ----
148
+
149
+ getResponse = () => toResponse(this.state.value);
142
150
 
143
151
  getState = () => JSON.stringify(this.state);
144
152
 
145
- checkValidity = () => {
146
- const input = this.getInputEl();
147
- if (!input) return undefined;
148
- return input.checkValidity();
153
+ setResponse = (response: QtiVariableJSON) => {
154
+ this.state = { value: fromResponse(response) };
155
+ this.render();
149
156
  };
150
157
 
151
- reportValidity = () => {
152
- const input = this.getInputEl();
153
- if (!input) return undefined;
154
- return input.reportValidity();
155
- };
158
+ checkValidity = () => this.input?.checkValidity() ?? true;
159
+
160
+ reportValidity = () => this.input?.reportValidity() ?? true;
156
161
 
157
162
  setCustomValidity = (message: string) => {
158
- this.state = { ...this.state, customValidity: message };
159
- const input = this.getInputEl();
160
- if (input) input.setCustomValidity(message);
163
+ this.customValidity = message ?? "";
164
+ this.input?.setCustomValidity(this.customValidity);
161
165
  };
162
166
 
163
- getCustomValidity = () => this.state.customValidity ?? "";
167
+ getCustomValidity = () => this.customValidity;
164
168
 
169
+ /** The engine is done with this instance: release what you hold (listeners on window, timers, ...). */
165
170
  oncompleted = () => {
166
- const input = this.getInputEl();
167
- if (input) {
168
- input.removeEventListener("input", this.onChange);
169
- input.removeEventListener("change", this.onChange);
170
- }
171
+ this.input?.removeEventListener("input", this.onInput);
171
172
  };
172
173
  }
173
174
 
174
175
  const factory: IMSpciFactory<PropTypes> = {
175
- typeIdentifier: "###___PCI_NAME___###",
176
+ typeIdentifier: "###___PCI_NAME___###", // the same as config.tspci.typeIdentifier in package.json
176
177
  getInstance: (dom, config, state) => new PciInstance(dom, config, state),
177
178
  };
178
179
 
179
- const engineContext =
180
- ctx && typeof (ctx as unknown as { register?: unknown }).register === "function"
181
- ? (ctx as unknown as { register: (f: IMSpciFactory<PropTypes>) => void })
182
- : typeof window !== "undefined" && (window as unknown as { qtiCustomInteractionContext?: unknown }).qtiCustomInteractionContext
183
- ? ((window as unknown as { qtiCustomInteractionContext: { register: (f: IMSpciFactory<PropTypes>) => void } })
184
- .qtiCustomInteractionContext as { register: (f: IMSpciFactory<PropTypes>) => void })
185
- : undefined;
186
-
187
- if (engineContext) {
188
- engineContext.register(factory);
189
- }
180
+ // Register with the engine: as an AMD module (qtiCustomInteractionContext), or through the global some engines set.
181
+ type Context = { register: (factory: IMSpciFactory<PropTypes>) => void };
182
+ const engineContext = [ctx as unknown as Context, (globalThis as { qtiCustomInteractionContext?: Context }).qtiCustomInteractionContext].find(
183
+ (c) => typeof c?.register === "function"
184
+ );
185
+ engineContext?.register(factory);
190
186
 
191
187
  export default factory;
@@ -0,0 +1,51 @@
1
+ import type { PciProperties } from "@citolab/tspci";
2
+ import configProps from "./config.json";
3
+
4
+ type PropTypes = PciProperties<typeof configProps>;
5
+
6
+ /** Settings arrive as strings (they are data- attributes) and an author may type anything: parse and clamp them. */
7
+ const toInt = (value: string, fallback: number, min: number, max: number) => {
8
+ const n = Number.parseInt(value, 10);
9
+ return Number.isNaN(n) ? fallback : Math.min(max, Math.max(min, n));
10
+ };
11
+
12
+ /** The pie the settings describe; the limits match the min/max in config.json. */
13
+ export function pieSettings(p: PropTypes) {
14
+ const segments = toInt(p.segments, 10, 2, 24);
15
+ return {
16
+ segments,
17
+ colored: toInt(p.colored, 0, 0, segments),
18
+ size: toInt(p.size, 200, 100, 400),
19
+ color: p.color || "#3b82f6",
20
+ };
21
+ }
22
+
23
+ /** An SVG circle of `segments` equal slices, the first `colored` ones filled, clockwise from the top. */
24
+ export function pieSvg(p: PropTypes): string {
25
+ const { segments, colored, size, color } = pieSettings(p);
26
+ const c = size / 2; // centre
27
+ const r = c - 4; // leave room for the outline
28
+ const point = (i: number) => {
29
+ const angle = -Math.PI / 2 + (2 * Math.PI * i) / segments;
30
+ return `${(c + r * Math.cos(angle)).toFixed(2)} ${(c + r * Math.sin(angle)).toFixed(2)}`;
31
+ };
32
+ const slices = Array.from(
33
+ { length: segments },
34
+ (_, i) =>
35
+ `<path class="slice" d="M ${c} ${c} L ${point(i)} A ${r} ${r} 0 0 1 ${point(i + 1)} Z"${
36
+ i < colored ? ` fill="${escapeHtml(color)}"` : ""
37
+ }/>`
38
+ ).join("");
39
+ // role="img" + aria-label: a screen reader reads one description instead of a list of paths.
40
+ // Translate the label when your items are not in English.
41
+ return `<svg class="pie" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}"
42
+ role="img" aria-label="A circle in ${segments} equal slices, ${colored} of them coloured">${slices}</svg>`;
43
+ }
44
+
45
+ /** Settings and answers end up in innerHTML: escape them, or an author's "<" breaks the markup. */
46
+ export const escapeHtml = (value: unknown) =>
47
+ String(value ?? "")
48
+ .replace(/&/g, "&amp;")
49
+ .replace(/</g, "&lt;")
50
+ .replace(/>/g, "&gt;")
51
+ .replace(/"/g, "&quot;");