CwAlertPointsEditor
Interactive number-line builder for alert points, thresholds, and ranges. The unit switch is visual-only, while the bound value and normalized output stay in Celsius.
All built-in labels, validation messages, empty states, and preview sentences can now be passed through the text prop. Rule names are still part of the bound value.points[] data, so translate those in your own state as well.
Each non-range rule also exposes an optional reset field for hysteresis. The preview draws that reset as the opposite side of the rule: > resets with <, >= resets with <=, and = resets anywhere that is not the trigger value.
Editor with reset on every point
The reset lane uses the same colour as the trigger and a reset icon marker. The high-temp reset below is fully covered by the cold-edge trigger, so it surfaces a validation error.
1 reset will never happen because every reset value is covered by another alert rule.
Reset will never happen because every reset value is covered by another alert rule.
Editor without reset on any point
The same starting points, but with the reset property omitted. The editor hides the reset input entirely — the existing API stays unchanged.
Reactive output — with reset
Normalized object preview
The reset field is forwarded as a Celsius number (or null when blank).
{
"center": 0,
"points": [
{
"id": "with-reset-1",
"name": "Cold edge",
"color": "#f7903b",
"condition": "lessThan",
"reset": 4,
"value": 0
},
{
"id": "with-reset-2",
"name": "Exact target",
"color": "#42edf0",
"condition": "equals",
"reset": null,
"value": 5
},
{
"id": "with-reset-3",
"name": "High temp alarm",
"color": "#e35c8d",
"condition": "greaterThan",
"reset": 0,
"value": 10
}
]
}Reactive output — no reset
Normalized object preview
No reset field is emitted because the source data never declared one.
{
"center": 0,
"points": [
{
"id": "no-reset-1",
"name": "Alert Point 1",
"color": "#f7903b",
"condition": "equals",
"value": 1.12
},
{
"id": "no-reset-2",
"name": "Alert Point 2",
"color": "#42edf0",
"condition": "range",
"min": 5,
"max": 10
},
{
"id": "no-reset-3",
"name": "High temp alarm",
"color": "#e35c8d",
"condition": "greaterThan",
"value": 30
}
]
}Copy-paste starter
Basic usage
Bind the full editor state, then normalize the Celsius-backed strings before sending them to an API.
<script lang="ts">
import { CwAlertPointsEditor } from '@cropwatchdevelopment/cwui';
import type { CwAlertPointsValue } from '@cropwatchdevelopment/cwui';
let alertPoints = $state<CwAlertPointsValue>({
unit: 'C',
center: '0',
points: []
});
</script>
<CwAlertPointsEditor bind:value={alertPoints} />Hysteresis
Optional reset value for rules
Add reset on any non-range rule to record the value at which a downstream alarm engine should clear the alert.
<script lang="ts">
import { CwAlertPointsEditor } from '@cropwatchdevelopment/cwui';
import type { CwAlertPointsValue } from '@cropwatchdevelopment/cwui';
// 'reset' is optional — provide it to capture a hysteresis threshold
// for downstream rule evaluation. Existing rules without 'reset' still work.
let alertPoints = $state<CwAlertPointsValue>({
unit: 'C',
center: '0',
points: [
{
id: 'high-temp',
name: 'High temp alarm',
color: '#e35c8d',
condition: 'greaterThanOrEqual',
value: '100',
min: '',
max: '',
reset: '22' // alert clears at or below 22 °C
}
]
});
</script>
<CwAlertPointsEditor bind:value={alertPoints} />Translation-ready
Pass every UI string through your i18n layer
Use the text prop for the component copy, and keep translated rule names in value.points[].name.
<script lang="ts">
import { CwAlertPointsEditor } from '@cropwatchdevelopment/cwui';
import type {
CwAlertPointsEditorText,
CwAlertPointsValue
} from '@cropwatchdevelopment/cwui';
import { t } from '$lib/i18n';
let alertPoints = $state<CwAlertPointsValue>({
unit: 'C',
center: '0',
points: [
{
id: 'alert-demo-1',
name: t('alerts.points.coldEdge'),
color: '#f7903b',
condition: 'lessThanOrEqual',
value: '-2',
min: '',
max: ''
}
]
});
const alertPointsText: CwAlertPointsEditorText = {
unitFieldLabel: t('alerts.labels.unit'),
centerFieldLabel: t('alerts.labels.center'),
nameFieldLabel: t('alerts.labels.name'),
conditionFieldLabel: t('alerts.labels.condition'),
valueFieldLabel: t('alerts.labels.value'),
minValueFieldLabel: t('alerts.labels.min'),
maxValueFieldLabel: t('alerts.labels.max'),
colorFieldLabel: t('alerts.labels.color'),
addAlertPointButton: t('alerts.actions.add'),
removePointButton: t('alerts.actions.remove'),
emptyTitle: t('alerts.empty.title'),
emptyDescription: t('alerts.empty.description'),
fieldLabelWithUnit: (label, unit) =>
t('alerts.format.fieldWithUnit', { label, unit }),
requiredFieldError: (label) =>
t('alerts.validation.required', { label }),
invalidPreviewNote: (count) =>
t('alerts.preview.invalidCount', { count }),
overlapPreviewNote: (count) =>
t('alerts.preview.overlapCount', { count }),
resetNeverHappensPreviewNote: (count) =>
t('alerts.preview.resetNeverHappensCount', { count }),
resetNeverHappensError: t('alerts.validation.resetNeverHappens'),
pointDescriptionEquals: (value, unit) =>
t('alerts.preview.equals', { value, unit }),
pointDescriptionRange: (min, max, unit) =>
t('alerts.preview.range', { min, max, unit }),
pointDescriptionLessThan: (value, unit) =>
t('alerts.preview.lessThan', { value, unit }),
pointDescriptionLessThanOrEqual: (value, unit) =>
t('alerts.preview.lessThanOrEqual', { value, unit }),
pointDescriptionGreaterThan: (value, unit) =>
t('alerts.preview.greaterThan', { value, unit }),
pointDescriptionGreaterThanOrEqual: (value, unit) =>
t('alerts.preview.greaterThanOrEqual', { value, unit }),
resetDescriptionWaitingForValue: t('alerts.preview.resetWaiting'),
resetDescriptionNotEquals: (value, unit) =>
t('alerts.preview.resetNotEquals', { value, unit }),
resetDescriptionLessThan: (value, unit) =>
t('alerts.preview.resetLessThan', { value, unit }),
resetDescriptionLessThanOrEqual: (value, unit) =>
t('alerts.preview.resetLessThanOrEqual', { value, unit }),
resetDescriptionGreaterThan: (value, unit) =>
t('alerts.preview.resetGreaterThan', { value, unit }),
resetDescriptionGreaterThanOrEqual: (value, unit) =>
t('alerts.preview.resetGreaterThanOrEqual', { value, unit }),
overlapError: (labels) =>
t('alerts.validation.overlap', { labels: labels.join(', ') })
};
</script>
<CwAlertPointsEditor bind:value={alertPoints} text={alertPointsText} />Start here
CwAlertPointsEditor is a number-line editor for threshold rules. It keeps the center point anchored, lets users add exact points, open-ended thresholds, and ranges, and keeps the bound values normalized in Celsius while the selected unit stays visual-only.
How to think about it
- Treat the bound value as editable form state Numeric fields stay as strings while the user types so partial negatives and decimals do not get wiped out mid-edit.
- Route built-in copy through the `text` prop Pass labels, validation messages, preview sentences, and empty-state copy into `text` so your translation library controls every built-in string.
- Use the condition dropdown to decide the shape `equals` draws a single point, `range` requires both `min` and `max`, and the less-than/greater-than variants render one-sided ranges.
- Add an optional reset for hysteresis-aware rules Every non-range condition exposes an optional `reset` input alongside the threshold value. The preview draws resets as the opposite side of the rule and warns when another alert rule fully covers the reset range.
- Normalize before you submit When you need a payload for storage or an API, convert the Celsius-backed `center`, `value`, `min`, `max`, and `reset` fields into numbers or `null`.
- Let the scale breathe around the center The number line expands symmetrically around the configured center point so negative values always stay left and positive values stay right.
Props and bound value shape
The component is intentionally optimized for editing. Numeric inputs are stored as strings inside the bound object.
| API | Type | Details |
|---|---|---|
value | CwAlertPointsValue | Bindable editor state containing the visual `unit`, the Celsius-backed `center`, and the editable `points[]` collection. |
text | CwAlertPointsEditorText | Optional copy overrides for labels, validation text, preview sentences, empty states, and condition labels. Dynamic fields accept formatter functions so an external translation library can interpolate counts, units, and lists. |
value.unit | 'C' | 'F' | 'K' | Visual-only unit rendered beside the center control and used in helper copy. The bound numeric fields remain in Celsius. |
value.center | string | Midpoint of the number line. Kept as a string while editing, stored in Celsius, and typically normalized to a number before saving. |
value.points | CwAlertPointRule[] | Editable list of alert rules. Each rule includes `id`, `name`, `color`, `condition`, `value`, `min`, `max`, and an optional `reset`. |
point.condition | 'equals' | 'range' | 'lessThan' | 'lessThanOrEqual' | 'greaterThan' | 'greaterThanOrEqual' | Controls whether the preview draws a point, a closed range, or a one-sided threshold. |
point.color | string | Hex colour used for the point or range and preserved in the bound output object. |
point.reset | string (optional) | Optional hysteresis threshold for downstream rule engines. Stored in Celsius like the other numeric fields and shown as the opposite reset range on the number line. Omit the field entirely on rules that do not need it — the editor will not add it. |
onchange | (value: CwAlertPointsValue) => void | Optional callback fired whenever the bound editor state changes. |
class | string | Optional class hook applied to the outer wrapper. |
Copy-paste examples
These snippets intentionally show the full public API surface the live demo relies on.
Bind the editor state
This is the minimal setup for interactive editing inside your own page or drawer.
<script lang="ts">
import { CwAlertPointsEditor } from '@cropwatchdevelopment/cwui';
import type { CwAlertPointsValue } from '@cropwatchdevelopment/cwui';
let alertPoints = $state<CwAlertPointsValue>({
unit: 'C',
center: '0',
points: []
});
</script>
<CwAlertPointsEditor bind:value={alertPoints} />Capture a hysteresis reset on a rule
`reset` is optional, so existing payloads keep working. Add it to any non-range rule to record the value at which a downstream alarm engine should clear the alert.
<script lang="ts">
import { CwAlertPointsEditor } from '@cropwatchdevelopment/cwui';
import type { CwAlertPointsValue } from '@cropwatchdevelopment/cwui';
let alertPoints = $state<CwAlertPointsValue>({
unit: 'C',
center: '0',
points: [
{
id: 'high-temp',
name: 'High temp alarm',
color: '#e35c8d',
condition: 'greaterThanOrEqual',
value: '100',
min: '',
max: '',
reset: '22' // alert clears once the value is at or below 22 °C
}
]
});
</script>
<CwAlertPointsEditor bind:value={alertPoints} />Translate the editor copy
Use the `text` prop for built-in UI strings and keep translated point names in your bound value.
<script lang="ts">
import { CwAlertPointsEditor } from '@cropwatchdevelopment/cwui';
import type {
CwAlertPointsEditorText,
CwAlertPointsValue
} from '@cropwatchdevelopment/cwui';
import { t } from '$lib/i18n';
let alertPoints = $state<CwAlertPointsValue>({
unit: 'C',
center: '0',
points: [
{
id: 'alert-1',
name: t('alerts.points.coldEdge'),
color: '#f7903b',
condition: 'lessThanOrEqual',
value: '-2',
min: '',
max: ''
}
]
});
const alertPointsText: CwAlertPointsEditorText = {
unitFieldLabel: t('alerts.labels.unit'),
centerFieldLabel: t('alerts.labels.center'),
nameFieldLabel: t('alerts.labels.name'),
conditionFieldLabel: t('alerts.labels.condition'),
valueFieldLabel: t('alerts.labels.value'),
minValueFieldLabel: t('alerts.labels.min'),
maxValueFieldLabel: t('alerts.labels.max'),
colorFieldLabel: t('alerts.labels.color'),
addAlertPointButton: t('alerts.actions.add'),
removePointButton: t('alerts.actions.remove'),
emptyTitle: t('alerts.empty.title'),
emptyDescription: t('alerts.empty.description'),
fieldLabelWithUnit: (label, unit) =>
t('alerts.format.fieldWithUnit', { label, unit }),
requiredFieldError: (label) =>
t('alerts.validation.required', { label }),
invalidPreviewNote: (count) =>
t('alerts.preview.invalidCount', { count }),
overlapPreviewNote: (count) =>
t('alerts.preview.overlapCount', { count }),
resetNeverHappensPreviewNote: (count) =>
t('alerts.preview.resetNeverHappensCount', { count }),
resetNeverHappensError: t('alerts.validation.resetNeverHappens'),
pointDescriptionEquals: (value, unit) =>
t('alerts.preview.equals', { value, unit }),
pointDescriptionRange: (min, max, unit) =>
t('alerts.preview.range', { min, max, unit }),
resetDescriptionWaitingForValue: t('alerts.preview.resetWaiting'),
resetDescriptionNotEquals: (value, unit) =>
t('alerts.preview.resetNotEquals', { value, unit }),
resetDescriptionLessThan: (value, unit) =>
t('alerts.preview.resetLessThan', { value, unit }),
resetDescriptionLessThanOrEqual: (value, unit) =>
t('alerts.preview.resetLessThanOrEqual', { value, unit }),
resetDescriptionGreaterThan: (value, unit) =>
t('alerts.preview.resetGreaterThan', { value, unit }),
resetDescriptionGreaterThanOrEqual: (value, unit) =>
t('alerts.preview.resetGreaterThanOrEqual', { value, unit }),
overlapError: (labels) =>
t('alerts.validation.overlap', { labels: labels.join(', ') })
};
</script>
<CwAlertPointsEditor bind:value={alertPoints} text={alertPointsText} />Normalize before API submit
Convert the Celsius-backed string fields into numbers once the user is done building the rule set. Forward `reset` only when the user supplied it.
<script lang="ts">
let alertPoints = $state({
unit: 'C',
center: '0',
points: [
{
id: 'alert-1',
name: 'Cold edge',
color: '#f7903b',
condition: 'lessThanOrEqual',
value: '-2',
min: '',
max: '',
reset: '0'
}
]
});
function toNumber(raw: string) {
const trimmed = raw.trim();
if (!trimmed) return null;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
const normalized = $derived({
center: toNumber(alertPoints.center) ?? 0,
points: alertPoints.points.map((point) => {
const base = point.condition === 'range'
? { ...point, min: toNumber(point.min), max: toNumber(point.max) }
: { ...point, value: toNumber(point.value) };
return point.reset === undefined
? base
: { ...base, reset: toNumber(point.reset) };
})
});
</script>