Live data
Dashboards drive shapes from live domain data: a charger card shows its current kW, a load bar grows with demand, a gauge follows a battery's charge. There are two ways to show live data, and a dashboard often uses both.
| Path | What changes | Use it when |
|---|---|---|
| Document edits without history | The diagram document: text, style, bounds. | The value belongs in the diagram: it is saved and exported, agents and rules see it, connectors follow a resized shape, and the canvas animates it. |
| Widget slots | Nothing in the document; a Flutter widget inside a shape rebuilds. | The value is presentation only, changes often, or needs a rich control such as a gauge or chart. Exports show the slot's fallback. |
Both read the data from one place: a store that the feed writes into.
Connect the feed through a store
Keep the transport, a WebSocket, a polling client or a message bus, out of the diagram. It writes readings into a MobX store; the diagram and widgets observe the store. Every snippet on this page uses these imports:
dart
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:mobx/mobx.dart' hide Listenable;
import 'package:vyuh_diagram_flutter/extension_api.dart';
import 'package:vyuh_diagram_flutter/vyuh_diagram_flutter.dart';
/// One reading from the charging site.
class ChargerReading {
const ChargerReading({required this.kw, required this.capacityKw});
final double kw;
final double capacityKw;
double get load => (kw / capacityKw).clamp(0, 1);
}
/// The latest reading, written by whatever feed is connected.
class ChargerStore {
final reading = Observable<ChargerReading?>(null);
StreamSubscription<ChargerReading>? _subscription;
void connect(Stream<ChargerReading> feed) {
_subscription?.cancel();
_subscription = feed.listen(
(next) => runInAction(() => reading.value = next),
);
}
void dispose() => _subscription?.cancel();
}Any source becomes a Stream. A WebSocket delivers messages; a polling API is fetched on an interval:
dart
/// Readings from WebSocket [messages], each a JSON object.
Stream<ChargerReading> webSocketReadings(Stream<Object?> messages) =>
messages.map((message) {
final json = jsonDecode(message as String) as Map<String, Object?>;
return ChargerReading(
kw: (json['kw'] as num).toDouble(),
capacityKw: (json['capacityKw'] as num).toDouble(),
);
});
/// Readings from [fetch], called every [interval].
Stream<ChargerReading> polledReadings(
Future<ChargerReading> Function() fetch, {
Duration interval = const Duration(seconds: 2),
}) async* {
while (true) {
yield await fetch();
await Future<void>.delayed(interval);
}
}Document edits without history
A reaction on the store writes each reading into the diagram with batch(label, build, history: false):
dart
/// Mirrors [telemetry] into [controller]. Dispose the reaction before the
/// controller.
ReactionDisposer bindCharger(
ChargerStore telemetry,
DiagramController controller,
) => reaction(
(_) => telemetry.reading.value,
(ChargerReading? reading) {
if (reading == null || controller.isDisposed) return;
controller.edits.batch('Charger telemetry', () {
final card = controller.document.shapeById('charger');
if (card == null) return; // The user deleted it.
controller.edits.setText('charger', '${reading.kw.round()} kW', slot: 'body');
controller.edits.setStyle(
ids: ['charger'],
fill: DiagramColor(reading.load > .8 ? 0xFFFFE3DC : 0xFFE8F5EC),
);
controller.edits.setElementBounds(
'charger-load',
DiagramRect.fromLTWH(
card.frame.left,
card.frame.bottom + 8,
card.frame.width * reading.load,
24,
),
motion: Motion.smooth,
);
}, history: false);
},
// Throttle: at most one diagram edit per 100 ms, with the latest value.
delay: 100,
);What history: false gives you:
- No undo entries. The user's undo stack holds only the user's edits, and the redo stack survives live updates.
- Undo stays correct. Undoing an earlier edit restores the fields that edit changed and keeps the live values: if the user recoloured the charger and a reading then changed its text, Undo restores the colour and keeps the kW. A later live write to the same field wins.
- Interactions continue. Updates apply while the user drags or types. A dragged shape keeps the frame the user is moving and shows the live text and colour; you don't need to pause the feed.
- The ordinary pipeline. Locks, rules and agents see the edit, connectors re-route, and
diagram.changesreports it withhistoryfalse.
Batching and throttling
Put everything one reading changes into one batch: one publication, one change event and one resolution, however many shapes it touches. Don't open a batch per field. When the feed is faster than the eye, throttle before the diagram, with the reaction's delay as above, or by sampling the stream. Each batch resolves only the shapes it changed and the connectors attached to them.
Animated bounds
setElementBounds, move, resize and layout take a motion. The document takes the final bounds at once, so hit testing, saving and agents see them; the canvas animates the shape, its text and ports, and its connectors there. A new reading mid-animation retargets smoothly. With the platform's reduce-motion setting, shapes jump. See animated geometry.
Autosave
Live edits are not the user's work. Skip them when marking the document dirty or autosaving; the next user edit saves the latest live values with it:
dart
/// Saves every edit the user can undo.
VoidCallback autosave(Diagram diagram, void Function(DiagramDocument) save) {
void listener() {
final change = diagram.changes.value;
if (change == null || !change.history) return;
save(change.after);
}
diagram.changes.addListener(listener);
return () => diagram.changes.removeListener(listener);
}Widget slots
A shape can host a Flutter widget in a slot. Bind the slot with DiagramFlutterExtension(widgets:) and rebuild it with an Observer: the widget follows the store without editing the document. Exports and hosts without the widget show the slot's fallback.
dart
/// A gauge shape whose dial follows [telemetry].
DiagramFlutterExtension chargerGauge(ChargerStore telemetry) =>
DiagramFlutterExtension(
id: 'charger.gauge',
shapes: [
ShapeDefinition(
type: 'charger.gauge',
name: 'Charger gauge',
size: const DiagramSize(180, 56),
body: ShapePadding(
8,
child: const ShapeWidget(
'charger.gauge.dial',
id: 'dial',
fallback: ShapeText(
'caption',
text: 'Charger load',
editable: false,
textType: PlainTextDefinition(maxLines: 1),
),
),
),
),
],
widgets: {
'charger.gauge.dial': (context, slot) => Observer(
builder: (_) => LinearProgressIndicator(
value: telemetry.reading.value?.load ?? 0,
),
),
},
);Own and dispose
The widget that owns the controller owns the store and the reaction too. Dispose in reverse: stop the feed, dispose the reaction, then the controller.
dart
class ChargerDashboard extends StatefulWidget {
const ChargerDashboard({super.key, required this.feed});
final Stream<ChargerReading> feed;
@override
State<ChargerDashboard> createState() => _ChargerDashboardState();
}
class _ChargerDashboardState extends State<ChargerDashboard> {
final telemetry = ChargerStore();
late final controller = DiagramController(
configuration: DiagramConfiguration(extensions: [chargerGauge(telemetry)]),
document: chargerDocument(),
);
late final ReactionDisposer binding;
@override
void initState() {
super.initState();
binding = bindCharger(telemetry, controller);
telemetry.connect(widget.feed);
}
@override
void dispose() {
telemetry.dispose();
binding();
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => DiagramCanvas(controller: controller);
}
DiagramDocument chargerDocument() => DiagramDocument(
id: 'charging-site',
revision: 0,
elements: [
ShapeElement(
id: 'charger',
type: Shapes.card,
frame: const DiagramRect.fromLTWH(0, 0, 180, 92),
textStory: TextStory(
id: 'charger-story',
blocks: [
TextParagraph(
id: 'charger-title',
slotId: 'title',
role: TextRole.title,
runs: [TextRun('EV charger')],
),
TextParagraph(
id: 'charger-kw',
slotId: 'body',
role: TextRole.body,
runs: [TextRun('Awaiting data')],
),
],
),
),
ShapeElement(
id: 'charger-load',
type: Shapes.rectangle,
frame: const DiagramRect.fromLTWH(0, 100, 24, 24),
),
ShapeElement(
id: 'charger-gauge',
type: 'charger.gauge',
frame: const DiagramRect.fromLTWH(0, 140, 180, 56),
),
],
);The live energy flow example streams simulated telemetry through a store into both paths, with a switch to pause the feed. Edit or drag its assets while it runs: Undo undoes only your edits.