[Feature] Adds timeseries display (#313)

Adds a way to input/display timeseries data to the additional information

Reviewed-on: enviPath/enviPy#313
Reviewed-by: jebus <lorsbach@envipath.com>
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
This commit is contained in:
2026-02-04 01:01:06 +13:00
committed by jebus
parent d80dfb5ee3
commit dc18b73e08
23 changed files with 1772 additions and 411 deletions

View File

@ -12,33 +12,84 @@
* endpoint: '/api/v1/scenario/{uuid}/information/temperature/'
* })">
*/
document.addEventListener('alpine:init', () => {
Alpine.data('schemaRenderer', (options = {}) => ({
document.addEventListener("alpine:init", () => {
// Global validation error store with context scoping
Alpine.store('validationErrors', {
errors: {},
// Set errors for a specific context (UUID) or globally (no context)
setErrors(errors, context = null) {
if (context) {
// Namespace all field names with context prefix
const namespacedErrors = {};
Object.entries(errors).forEach(([field, messages]) => {
const key = `${context}.${field}`;
namespacedErrors[key] = messages;
});
// Merge into existing errors (preserves other contexts)
this.errors = { ...this.errors, ...namespacedErrors };
} else {
// No context - merge as-is for backward compatibility
this.errors = { ...this.errors, ...errors };
}
},
// Clear errors for a specific context or all errors
clearErrors(context = null) {
if (context) {
// Clear only errors for this context
const newErrors = {};
const prefix = `${context}.`;
Object.keys(this.errors).forEach(key => {
if (!key.startsWith(prefix)) {
newErrors[key] = this.errors[key];
}
});
this.errors = newErrors;
} else {
// Clear all errors
this.errors = {};
}
},
// Clear a specific field, optionally within a context
clearField(fieldName, context = null) {
const key = context ? `${context}.${fieldName}` : fieldName;
if (this.errors[key]) {
delete this.errors[key];
// Trigger reactivity by creating new object
this.errors = { ...this.errors };
}
},
// Check if a field has errors, optionally within a context
hasError(fieldName, context = null) {
const key = context ? `${context}.${fieldName}` : fieldName;
return Array.isArray(this.errors[key]) && this.errors[key].length > 0;
},
// Get errors for a field, optionally within a context
getErrors(fieldName, context = null) {
const key = context ? `${context}.${fieldName}` : fieldName;
return this.errors[key] || [];
}
});
Alpine.data("schemaRenderer", (options = {}) => ({
schema: null,
uiSchema: {},
data: {},
mode: options.mode || 'view', // 'view' | 'edit'
endpoint: options.endpoint || '',
mode: options.mode || "view", // 'view' | 'edit'
endpoint: options.endpoint || "",
loading: false,
error: null,
fieldErrors: {}, // Server-side field-level errors
context: options.context || null, // UUID for items, null for single forms
debugErrors:
options.debugErrors ??
(typeof window !== "undefined" &&
window.location?.search?.includes("debugErrors=1")),
async init() {
// Listen for field error events from parent modal
window.addEventListener('set-field-errors', (e) => {
// Apply to all forms (used by add modal which has only one form)
this.fieldErrors = e.detail || {};
});
// Listen for field error events targeted to a specific item (for update modal)
window.addEventListener('set-field-errors-for-item', (e) => {
// Only update if this form matches the UUID
const itemData = options.data || {};
if (itemData.uuid === e.detail?.uuid) {
this.fieldErrors = e.detail.fieldErrors || {};
}
});
if (options.schemaUrl) {
try {
this.loading = true;
@ -50,31 +101,31 @@ document.addEventListener('alpine:init', () => {
// RJSF format: {schema, uiSchema, formData, groups}
if (!rjsf.schema) {
throw new Error('Invalid RJSF format: missing schema property');
throw new Error("Invalid RJSF format: missing schema property");
}
this.schema = rjsf.schema;
this.uiSchema = rjsf.uiSchema || {};
this.data = options.data
? JSON.parse(JSON.stringify(options.data))
: (rjsf.formData || {});
: rjsf.formData || {};
} catch (err) {
this.error = err.message;
console.error('Error loading schema:', err);
console.error("Error loading schema:", err);
} finally {
this.loading = false;
}
} else if (options.rjsf) {
// Direct RJSF object passed
if (!options.rjsf.schema) {
throw new Error('Invalid RJSF format: missing schema property');
throw new Error("Invalid RJSF format: missing schema property");
}
this.schema = options.rjsf.schema;
this.uiSchema = options.rjsf.uiSchema || {};
this.data = options.data
? JSON.parse(JSON.stringify(options.data))
: (options.rjsf.formData || {});
: options.rjsf.formData || {};
}
// Initialize data from formData or options
@ -84,19 +135,22 @@ document.addEventListener('alpine:init', () => {
// Ensure all schema fields are properly initialized
if (this.schema && this.schema.properties) {
for (const [key, propSchema] of Object.entries(this.schema.properties)) {
for (const [key, propSchema] of Object.entries(
this.schema.properties,
)) {
const widget = this.getWidget(key, propSchema);
if (widget === 'interval') {
if (widget === "interval") {
// Ensure interval fields are objects with start/end
if (!this.data[key] || typeof this.data[key] !== 'object') {
if (!this.data[key] || typeof this.data[key] !== "object") {
this.data[key] = { start: null, end: null };
} else {
// Ensure start and end exist
if (this.data[key].start === undefined) this.data[key].start = null;
if (this.data[key].start === undefined)
this.data[key].start = null;
if (this.data[key].end === undefined) this.data[key].end = null;
}
} else if (widget === 'timeseries-table') {
} else if (widget === "timeseries-table") {
// Ensure timeseries fields are arrays
if (!this.data[key] || !Array.isArray(this.data[key])) {
this.data[key] = [];
@ -104,86 +158,141 @@ document.addEventListener('alpine:init', () => {
} else if (this.data[key] === undefined) {
// ONLY initialize if truly undefined, not just falsy
// This preserves empty strings, null, 0, false as valid values
if (propSchema.type === 'boolean') {
if (propSchema.type === "boolean") {
this.data[key] = false;
} else if (propSchema.type === 'number' || propSchema.type === 'integer') {
} else if (
propSchema.type === "number" ||
propSchema.type === "integer"
) {
this.data[key] = null;
} else if (propSchema.enum) {
// For select fields, use null to show placeholder
this.data[key] = null;
} else {
this.data[key] = '';
this.data[key] = "";
}
}
// If data[key] exists (even if empty string or null), don't overwrite
}
}
// UX: Clear field errors when fields change (with context)
if (this.mode === "edit" && this.schema?.properties) {
Object.keys(this.schema.properties).forEach((key) => {
this.$watch(
`data.${key}`,
() => {
Alpine.store('validationErrors').clearField(key, this.context);
},
{ deep: true },
);
});
}
},
getWidget(fieldName, fieldSchema) {
// Check uiSchema first (RJSF format)
if (this.uiSchema[fieldName] && this.uiSchema[fieldName]['ui:widget']) {
return this.uiSchema[fieldName]['ui:widget'];
}
// Defensive check: ensure fieldSchema is provided
if (!fieldSchema) return "text";
// Check for interval type (object with start/end properties)
if (fieldSchema.type === 'object' &&
try {
// Check uiSchema first (RJSF format)
if (
this.uiSchema &&
this.uiSchema[fieldName] &&
this.uiSchema[fieldName]["ui:widget"]
) {
return this.uiSchema[fieldName]["ui:widget"];
}
// Check for interval type (object with start/end properties)
if (
fieldSchema.type === "object" &&
fieldSchema.properties &&
fieldSchema.properties.start &&
fieldSchema.properties.end) {
return 'interval';
}
fieldSchema.properties.end
) {
return "interval";
}
// Infer from JSON Schema type
if (fieldSchema.enum) return 'select';
if (fieldSchema.type === 'number' || fieldSchema.type === 'integer') return 'number';
if (fieldSchema.type === 'boolean') return 'checkbox';
return 'text';
// Check for measurements array type (timeseries-table widget)
if (
fieldSchema.type === "array" &&
fieldSchema.items?.properties?.timestamp &&
fieldSchema.items?.properties?.value
) {
return "timeseries-table";
}
// Infer from JSON Schema type
if (fieldSchema.enum) return "select";
if (fieldSchema.type === "number" || fieldSchema.type === "integer")
return "number";
if (fieldSchema.type === "boolean") return "checkbox";
return "text";
} catch (e) {
// Fallback to text widget if anything fails
console.warn("Error in getWidget:", e);
return "text";
}
},
getLabel(fieldName, fieldSchema) {
// Check uiSchema (RJSF format)
if (this.uiSchema[fieldName] && this.uiSchema[fieldName]['ui:label']) {
return this.uiSchema[fieldName]['ui:label'];
if (this.uiSchema[fieldName] && this.uiSchema[fieldName]["ui:label"]) {
return this.uiSchema[fieldName]["ui:label"];
}
// Default: format field name
return fieldName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
},
getHelp(fieldName) {
// Get help text from uiSchema
if (this.uiSchema[fieldName] && this.uiSchema[fieldName]['ui:help']) {
return this.uiSchema[fieldName]['ui:help'];
}
return null;
},
getPlaceholder(fieldName) {
// Get placeholder from uiSchema
if (this.uiSchema[fieldName] && this.uiSchema[fieldName]['ui:placeholder']) {
return this.uiSchema[fieldName]['ui:placeholder'];
}
return null;
return fieldName
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
},
getFieldOrder() {
// Get ordered list of field names based on ui:order
if (!this.schema || !this.schema.properties) return [];
try {
// Get ordered list of field names based on ui:order
if (!this.schema || !this.schema.properties) return [];
const fields = Object.keys(this.schema.properties);
// Only include fields that have UI configs
const fields = Object.keys(this.schema.properties).filter(
(fieldName) => this.uiSchema && this.uiSchema[fieldName],
);
// Sort by ui:order if available
return fields.sort((a, b) => {
const orderA = this.uiSchema[a]?.['ui:order'] || '999';
const orderB = this.uiSchema[b]?.['ui:order'] || '999';
return parseInt(orderA) - parseInt(orderB);
});
// Sort by ui:order if available
return fields.sort((a, b) => {
const orderA = this.uiSchema[a]?.["ui:order"] || "999";
const orderB = this.uiSchema[b]?.["ui:order"] || "999";
return parseInt(orderA) - parseInt(orderB);
});
} catch (e) {
// Return empty array if anything fails to prevent errors
console.warn("Error in getFieldOrder:", e);
return [];
}
},
hasTimeseriesField() {
try {
// Check if any field in the schema is a timeseries-table widget
if (!this.schema || !this.schema.properties) {
return false;
}
return Object.keys(this.schema.properties).some((fieldName) => {
const fieldSchema = this.schema.properties[fieldName];
if (!fieldSchema) return false;
return this.getWidget(fieldName, fieldSchema) === "timeseries-table";
});
} catch (e) {
// Return false if anything fails to prevent errors
console.warn("Error in hasTimeseriesField:", e);
return false;
}
},
async submit() {
if (!this.endpoint) {
console.error('No endpoint specified for submission');
console.error("No endpoint specified for submission");
return;
}
@ -191,14 +300,15 @@ document.addEventListener('alpine:init', () => {
this.error = null;
try {
const csrftoken = document.querySelector("[name=csrf-token]")?.content || '';
const csrftoken =
document.querySelector("[name=csrf-token]")?.content || "";
const res = await fetch(this.endpoint, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken
"Content-Type": "application/json",
"X-CSRFToken": csrftoken,
},
body: JSON.stringify(this.data)
body: JSON.stringify(this.data),
});
if (!res.ok) {
@ -210,35 +320,50 @@ document.addEventListener('alpine:init', () => {
}
// Handle validation errors (field-level)
this.fieldErrors = {};
Alpine.store('validationErrors').clearErrors();
// Try to parse structured error response
let parsedError = errorData;
// If error is a JSON string, parse it
if (typeof errorData.error === 'string' && errorData.error.startsWith('{')) {
if (
typeof errorData.error === "string" &&
errorData.error.startsWith("{")
) {
parsedError = JSON.parse(errorData.error);
}
if (parsedError.detail && Array.isArray(parsedError.detail)) {
// Pydantic validation errors format: [{loc: ['field'], msg: '...', type: '...'}]
const fieldErrors = {};
for (const err of parsedError.detail) {
const field = err.loc && err.loc.length > 0 ? err.loc[err.loc.length - 1] : 'root';
if (!this.fieldErrors[field]) {
this.fieldErrors[field] = [];
const field =
err.loc && err.loc.length > 0
? err.loc[err.loc.length - 1]
: "root";
if (!fieldErrors[field]) {
fieldErrors[field] = [];
}
this.fieldErrors[field].push(err.msg || err.message || 'Validation error');
fieldErrors[field].push(
err.msg || err.message || "Validation error",
);
}
throw new Error('Validation failed. Please check the fields below.');
Alpine.store('validationErrors').setErrors(fieldErrors);
throw new Error(
"Validation failed. Please check the fields below.",
);
} else {
// General error
throw new Error(parsedError.error || parsedError.detail || `Request failed: ${res.statusText}`);
throw new Error(
parsedError.error ||
parsedError.detail ||
`Request failed: ${res.statusText}`,
);
}
}
// Clear errors on success
this.fieldErrors = {};
Alpine.store('validationErrors').clearErrors();
const result = await res.json();
return result;
@ -248,6 +373,6 @@ document.addEventListener('alpine:init', () => {
} finally {
this.loading = false;
}
}
},
}));
});

View File

@ -4,15 +4,24 @@
* Centralized widget component definitions for dynamic form rendering.
* Each widget receives explicit parameters instead of context object for better traceability.
*/
document.addEventListener('alpine:init', () => {
document.addEventListener("alpine:init", () => {
// Base widget factory with common functionality
const baseWidget = (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({
const baseWidget = (
fieldName,
data,
schema,
uiSchema,
fieldErrors,
mode,
debugErrors,
context = null // NEW: context for error namespacing
) => ({
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context, // Store context for use in templates
// Field schema access
get fieldSchema() {
@ -22,165 +31,432 @@ document.addEventListener('alpine:init', () => {
// Common metadata
get label() {
// Check uiSchema first (RJSF format)
if (this.uiSchema?.[this.fieldName]?.['ui:label']) {
return this.uiSchema[this.fieldName]['ui:label'];
if (this.uiSchema?.[this.fieldName]?.["ui:label"]) {
return this.uiSchema[this.fieldName]["ui:label"];
}
// Fall back to schema title
if (this.fieldSchema.title) {
return this.fieldSchema.title;
}
// Default: format field name
return this.fieldName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
return this.fieldName
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
},
get helpText() {
return this.fieldSchema.description || '';
return this.fieldSchema.description || "";
},
// Field-level unit extraction from uiSchema (RJSF format)
get unit() {
return this.uiSchema?.[this.fieldName]?.['ui:unit'] || null;
},
// Error handling
get hasError() {
return !!this.fieldErrors?.[this.fieldName];
},
get errors() {
return this.fieldErrors?.[this.fieldName] || [];
return this.uiSchema?.[this.fieldName]?.["ui:unit"] || null;
},
// Mode checks
get isViewMode() { return this.mode === 'view'; },
get isEditMode() { return this.mode === 'edit'; },
get isViewMode() {
return this.mode === "view";
},
get isEditMode() {
return this.mode === "edit";
},
});
// Text widget
Alpine.data('textWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode),
Alpine.data(
"textWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() { return this.data[this.fieldName] || ''; },
set value(v) { this.data[this.fieldName] = v; },
}));
get value() {
return this.data[this.fieldName] || "";
},
set value(v) {
this.data[this.fieldName] = v;
},
}),
);
// Textarea widget
Alpine.data('textareaWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode),
Alpine.data(
"textareaWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() { return this.data[this.fieldName] || ''; },
set value(v) { this.data[this.fieldName] = v; },
}));
get value() {
return this.data[this.fieldName] || "";
},
set value(v) {
this.data[this.fieldName] = v;
},
}),
);
// Number widget with unit support
Alpine.data('numberWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode),
Alpine.data(
"numberWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() { return this.data[this.fieldName]; },
set value(v) {
this.data[this.fieldName] = v === '' || v === null ? null : parseFloat(v);
},
get hasValue() {
return this.value !== null && this.value !== undefined && this.value !== '';
},
// Format value with unit for view mode
get displayValue() {
if (!this.hasValue) return '—';
return this.unit ? `${this.value} ${this.unit}` : String(this.value);
},
}));
get value() {
return this.data[this.fieldName];
},
set value(v) {
this.data[this.fieldName] =
v === "" || v === null ? null : parseFloat(v);
},
get hasValue() {
return (
this.value !== null && this.value !== undefined && this.value !== ""
);
},
// Format value with unit for view mode
get displayValue() {
if (!this.hasValue) return "—";
return this.unit ? `${this.value} ${this.unit}` : String(this.value);
},
}),
);
// Select widget
Alpine.data('selectWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode),
Alpine.data(
"selectWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() { return this.data[this.fieldName] || ''; },
set value(v) { this.data[this.fieldName] = v; },
get options() { return this.fieldSchema.enum || []; },
}));
get value() {
return this.data[this.fieldName] || "";
},
set value(v) {
this.data[this.fieldName] = v;
},
get options() {
return this.fieldSchema.enum || [];
},
}),
);
// Checkbox widget
Alpine.data('checkboxWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode),
Alpine.data(
"checkboxWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get checked() { return !!this.data[this.fieldName]; },
set checked(v) { this.data[this.fieldName] = v; },
}));
get checked() {
return !!this.data[this.fieldName];
},
set checked(v) {
this.data[this.fieldName] = v;
},
}),
);
// Interval widget with unit support
Alpine.data('intervalWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode),
Alpine.data(
"intervalWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get start() {
return this.data[this.fieldName]?.start ?? null;
},
set start(v) {
if (!this.data[this.fieldName]) this.data[this.fieldName] = {};
this.data[this.fieldName].start = v === '' || v === null ? null : parseFloat(v);
},
get end() {
return this.data[this.fieldName]?.end ?? null;
},
set end(v) {
if (!this.data[this.fieldName]) this.data[this.fieldName] = {};
this.data[this.fieldName].end = v === '' || v === null ? null : parseFloat(v);
},
// Format interval with unit for view mode
get displayValue() {
const s = this.start, e = this.end;
const unitStr = this.unit ? ` ${this.unit}` : '';
get start() {
return this.data[this.fieldName]?.start ?? null;
},
set start(v) {
if (!this.data[this.fieldName]) this.data[this.fieldName] = {};
this.data[this.fieldName].start =
v === "" || v === null ? null : parseFloat(v);
},
get end() {
return this.data[this.fieldName]?.end ?? null;
},
set end(v) {
if (!this.data[this.fieldName]) this.data[this.fieldName] = {};
this.data[this.fieldName].end =
v === "" || v === null ? null : parseFloat(v);
},
// Format interval with unit for view mode
get displayValue() {
const s = this.start,
e = this.end;
const unitStr = this.unit ? ` ${this.unit}` : "";
if (s !== null && e !== null) return `${s} ${e}${unitStr}`;
if (s !== null) return `${s}${unitStr}`;
if (e !== null) return `${e}${unitStr}`;
return '—';
},
if (s !== null && e !== null) return `${s} ${e}${unitStr}`;
if (s !== null) return `${s}${unitStr}`;
if (e !== null) return `${e}${unitStr}`;
return "—";
},
get isSameValue() {
return this.start !== null && this.start === this.end;
},
get isSameValue() {
return this.start !== null && this.start === this.end;
},
// Validation: start must be <= end
get hasValidationError() {
if (this.isViewMode) return false;
const s = this.start;
const e = this.end;
// Only validate if both values are provided
if (s !== null && e !== null && typeof s === 'number' && typeof e === 'number') {
return s > e;
}
return false;
},
// Override hasError to include validation error
get hasError() {
return this.hasValidationError || !!this.fieldErrors?.[this.fieldName];
},
// Override errors to include validation error message
get errors() {
const serverErrors = this.fieldErrors?.[this.fieldName] || [];
const validationErrors = this.hasValidationError
? ['Start value must be less than or equal to end value']
: [];
return [...validationErrors, ...serverErrors];
},
}));
// Validation: start must be <= end (client-side)
get hasValidationError() {
if (this.isViewMode) return false;
const s = this.start;
const e = this.end;
// Only validate if both values are provided
if (
s !== null &&
e !== null &&
typeof s === "number" &&
typeof e === "number"
) {
return s > e;
}
return false;
},
}),
);
// PubMed link widget
Alpine.data('pubmedWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode),
Alpine.data(
"pubmedWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() { return this.data[this.fieldName] || ''; },
set value(v) { this.data[this.fieldName] = v; },
get pubmedUrl() {
return this.value ? `https://pubmed.ncbi.nlm.nih.gov/${this.value}` : null;
},
}));
get value() {
return this.data[this.fieldName] || "";
},
set value(v) {
this.data[this.fieldName] = v;
},
get pubmedUrl() {
return this.value
? `https://pubmed.ncbi.nlm.nih.gov/${this.value}`
: null;
},
}),
);
// Compound link widget
Alpine.data('compoundWidget', (fieldName, data, schema, uiSchema, fieldErrors, mode) => ({
...baseWidget(fieldName, data, schema, uiSchema, fieldErrors, mode),
Alpine.data(
"compoundWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() { return this.data[this.fieldName] || ''; },
set value(v) { this.data[this.fieldName] = v; },
}));
get value() {
return this.data[this.fieldName] || "";
},
set value(v) {
this.data[this.fieldName] = v;
},
}),
);
// TimeSeries table widget
Alpine.data(
"timeseriesTableWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
chartInstance: null,
// Getter/setter for measurements array
get measurements() {
return this.data[this.fieldName] || [];
},
set measurements(v) {
this.data[this.fieldName] = v;
},
// Get description from sibling field
get description() {
return this.data?.description || "";
},
// Get method from sibling field
get method() {
return this.data?.method || "";
},
// Computed property for chart options
get chartOptions() {
return {
measurements: this.measurements,
xAxisLabel: this.data?.x_axis_label || "Time",
yAxisLabel: this.data?.y_axis_label || "Value",
xAxisUnit: this.data?.x_axis_unit || "",
yAxisUnit: this.data?.y_axis_unit || "",
};
},
// Add new measurement
addMeasurement() {
if (!this.data[this.fieldName]) {
this.data[this.fieldName] = [];
}
this.data[this.fieldName].push({
timestamp: null,
value: null,
error: null,
note: "",
});
},
// Remove measurement by index
removeMeasurement(index) {
if (
this.data[this.fieldName] &&
Array.isArray(this.data[this.fieldName])
) {
this.data[this.fieldName].splice(index, 1);
}
},
// Update specific measurement field
updateMeasurement(index, field, value) {
if (this.data[this.fieldName] && this.data[this.fieldName][index]) {
if (field === "timestamp" || field === "value" || field === "error") {
// Parse all numeric fields (timestamp is days as float)
this.data[this.fieldName][index][field] =
value === "" || value === null ? null : parseFloat(value);
} else {
// Store other fields as-is
this.data[this.fieldName][index][field] = value;
}
}
},
// Format timestamp for display (timestamp is numeric days as float)
formatTimestamp(timestamp) {
return timestamp ?? "";
},
// Sort by timestamp (numeric days)
sortByTimestamp() {
if (
this.data[this.fieldName] &&
Array.isArray(this.data[this.fieldName])
) {
this.data[this.fieldName].sort((a, b) => {
const tsA = a.timestamp ?? Infinity;
const tsB = b.timestamp ?? Infinity;
return tsA - tsB;
});
}
},
// Chart lifecycle methods (delegates to TimeSeriesChart utility)
initChart() {
if (!this.isViewMode || !window.Chart || !window.TimeSeriesChart)
return;
const canvas = this.$refs?.chartCanvas;
if (!canvas) return;
this.destroyChart();
if (this.measurements.length === 0) return;
this.chartInstance = window.TimeSeriesChart.create(
canvas,
this.chartOptions,
);
},
updateChart() {
if (!this.chartInstance || !this.isViewMode) return;
window.TimeSeriesChart.update(
this.chartInstance,
this.measurements,
this.chartOptions,
);
},
destroyChart() {
if (this.chartInstance) {
window.TimeSeriesChart.destroy(this.chartInstance);
this.chartInstance = null;
}
},
// Alpine lifecycle hooks
init() {
if (this.isViewMode && window.Chart) {
// Use $nextTick to ensure DOM is ready
this.$nextTick(() => {
this.initChart();
});
// Watch measurements array for changes and update chart
this.$watch("data." + this.fieldName, () => {
if (this.chartInstance) {
this.updateChart();
}
});
}
},
}),
);
});