forked from enviPath/enviPy
[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:
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user