Custom Visualization+ document reference
This page shows exactly what an AI receives when you select Copy AI prompt in the code editor of Custom Visualization+: the rules a document must follow, the vlp functions and data it can use, and 12 complete example documents. Read it when you write or review a document yourself, or to see why an AI wrote a document the way it did.
Overview
The copied prompt contains this text plus the live context of your symbol: its data sources with labels, units, states and limits, the theme, the viewer's formats and time zone, and the settings, selections and filters the document declares. Copy AI prompt (change) also adds your current document. This page is generated from the prompt of this release, so it always matches the product. The prompt is written for an AI, which is why it addresses the AI and is in English.
Rules
- Write ONE self-contained HTML document: all HTML, CSS and JavaScript inline in that one document.
- Keep the whole document under 256,000 characters: that is the most the symbol stores as inline code. Never embed a large library or large data in the document (use the bundled libraries). If the visualization really needs a longer document, say so: it then has to be stored in an AF attribute (Code source: AF attribute).
- The document runs in a sandboxed iframe with a strict Content Security Policy:
- No network requests: fetch, XMLHttpRequest, WebSocket and EventSource are blocked.
- No eval, no new Function, no setTimeout or setInterval with a string argument.
- No localStorage, sessionStorage, IndexedDB or cookies.
- No external scripts, style sheets, fonts or images, and no CDN libraries or frameworks: write plain JavaScript, CSS, inline SVG or canvas, or use the two bundled libraries (ECharts and Tabulator, see "Libraries" below). Images and fonts only as data: URLs.
- No alert, confirm, prompt, popups, form submissions or navigation.
- Fill the frame: set html and body to margin 0 and height 100%, size everything to the given width and height, and react to vlp.onResize. Never make the page scroll sideways: with many columns, bars or labels, condense (fewer decimals, shorter headers, a smaller font, fewer axis labels) until everything fits the width. Fit the height as well: lay the page out from the frame's height, so the main content shrinks with the frame and every part stays visible or reachable; only a long list may scroll, inside its own area, and a fixed height must not hide part of a short one.
- Take every colour and the font from the theme: the CSS variables (--vlp-foreground, --vlp-text-muted, --vlp-accent, --vlp-border, --vlp-divider, --vlp-surface, --vlp-surface-alternate, --vlp-surface-raised, --vlp-series-1 to --vlp-series-8, --vlp-font-family, --vlp-good, --vlp-warning, --vlp-bad, --vlp-neutral) or payload.theme. Never write a fixed colour or font. Leave the page background transparent and draw no outer border, radius or shadow: the symbol draws its container around your frame, and size.width / height are the space inside it. Paint state.color where a source has one. The one exception is the value of a color parameter (see "Parameters" below): the symbol's author chose it; when it is "", use the theme colour.
- Never block the main thread: no busy loops or long synchronous work. When the document animates something that does not follow the data, such as the blink of state.blink, animate it with CSS on opacity or transform; never put a CSS transition on SVG geometry (x, y, width, height, points, d).
- The symbol can sit in a PI Vision collection, which runs the same document once per asset, side by side: keep it light, never assume it is alone on the display, and name its sources from payload.sources (each copy gets the labels of its own asset), never with names written into the code.
- Do not animate data updates: PI Vision sends new data every few seconds, so an animation would replay on every update. With ECharts set animation: false in every option you pass to setOption. In HTML and SVG use no CSS transition or animation and no requestAnimationFrame tweening on anything that changes with the data (values, sizes, positions, colours): set the new value at once. The one exception is a state signal such as the blink of state.blink; short feedback that does not follow the data, such as a hover style, is fine.
- Build the SVG or DOM structure once, then only change attributes, styles and text. Never remove or replace an element your code still uses later. Elements whose number follows the data (table rows, bars, points) may be rebuilt as a group, or kept in a pool that grows and hides the unused ones.
- Handle missing data: a source may be absent, its value may be null, and good may be false (bad value or no data). Show that state instead of failing.
- Redraw only when what you show changed (new values, history or figures, size or theme), not on every vlp.onData that brings nothing new for it. With ECharts keep one chart and update it with setOption of the changed data, without notMerge, so the chart keeps its state (tooltip, legend selection, zoom) and processes only what changed. Only when the number or identity of the series changes (a source added or removed, an asset swap) use setOption(option, { replaceMerge: ["series"] }), so removed series disappear; never otherwise, since replaceMerge creates the series anew.
- Show every number and time the viewer reads through vlp.formatNumber and vlp.formatTime: they format like the other symbols on the display (the viewer's decimal mark, digit grouping and date order, and the display time zone). Never use toFixed, toLocaleString, toLocaleTimeString or Intl with [] or without a locale for viewer text. Use Intl only for what the two do not cover, and then with payload.locale.tag and, for times, timeZone: payload.locale.timeZone (left out when it is null). Coordinates, sizes and CSS values are no viewer text: compute those with plain numbers.
- When my request calls something adjustable per symbol (a title, limits, a number of rows, a colour, a mode), declare it as a parameter in the vlp-manifest block (see "Parameters" below) instead of writing it into the code, and read its value from payload.parameters in every vlp.onData: the symbol's author can change it at any time, so redraw when it changed. Never rename a parameter when you change the document (the values the author saved under the old name would be lost); add a new one instead. Show a text parameter with textContent, never as HTML. A color parameter whose value is "" means the theme colour.
- Call vlp.setTimeRange and vlp.select only from a click or key handler of an element the viewer operated, never from vlp.onData, vlp.onResize, a timer or at load (those calls are refused). Draw a zoom affordance (a zoom-in cursor, a hint such as "click a day to show it") only while payload.timeRangeEnabled is true. When your code selects with vlp.select, mark the selected item and offer a visible way to clear the selection (vlp.select(name, [])). A click on the item that is already selected clears the selection. When the selected item is no longer in the data (the sources changed), keep the selection published and the clear control visible, so the viewer can clear it.
- Write UI text (captions, headings, messages) in English. When my request names other languages, keep the texts in a small table keyed by payload.locale.language with English as the fallback, and set dir from payload.locale.direction on the element that holds the translated texts.
- When the range spans more than one day, put the date in every time label and in any text that names a time (vlp.formatTime with "dateTime", or "date" and "time" together, never "time" alone).
- Put the <script> element at the end of <body>, so the elements it uses already exist.
- Wrap all your script code in a function, such as (function () { ... })();, and never give your own top-level variables or functions the names of browser globals (for example history, name, status, top, parent, length, location, origin, event, screen, close, open, print, stop, scroll): those are window properties, so an assignment to them is silently ignored, changes its type or navigates.
- Reply with the complete HTML document only, starting with <!DOCTYPE html>. No explanation, no Markdown code fence.
The vlp API
The frame defines one global object, vlp, before your code runs (API version 1, kind "datasources"):
- vlp.apiVersion is 1 and vlp.kind is "datasources".
- vlp.onData(fn): fn(payload) runs on every data update, and at once if data has already arrived.
- vlp.onResize(fn): fn(size) runs when the symbol is resized; size is { width, height } in CSS pixels.
- vlp.onTheme(fn): fn(theme) runs when the theme changes, which can happen at any time: the author switches the symbol's theme, an administrator changes a corporate theme, or (with the Custom theme) the display background changes. Restyle everything from the new theme; what reads the CSS variables follows by itself.
- vlp.history(options): returns a Promise of the history of the symbol's own sources over the display time range (payload.timeRange); no other range or sources. options is optional: { sources: [0, 1], mode: "plot", maxPoints: 500 }; any other option is refused.
- sources: 0-based source indexes; default: all sources. One index the symbol does not have rejects the whole request.
- mode: "plot" (default: the shape of the trend, as PI Vision draws it), "recorded" (the archived values; the newest maxPoints when there are more) or "interpolated" (values at even steps from the range start).
- maxPoints: the most values per source; default 500, at most 5000 (a larger number is lowered to 5000).
- It resolves to { "sources": [{ "index": 0, "label": "Reactor temperature", "uom": "degC", "times": ["2026-10-28T02:00:00.000Z", "2026-10-28T02:05:00.000Z"], "values": [71.9, 72.1], "good": [true, true], "before": { "time": "2026-10-28T01:58:30.000Z", "value": 71.8, "good": true }, "truncated": false }] }: one entry per requested source, in the order requested. times are ascending ISO 8601 UTC strings within the requested range (payload.timeRange when you asked, start and end included); values (a number, a string or null) and good (false for a bad value or no data) are parallel to times.
- before: the value in effect at the range start, { time, value, good }, or null: the latest value at or before timeRange.start (a value exactly at the start: that value is then also times[0]). From the start until times[0] (or the end when times is empty) the value was before.value; use it for durations. "recorded" gives the last archived value before the range, "plot" and "interpolated" the value at the start when PI returns one, else null. A state that did not change in the range has empty times and only before.
- truncated: true when maxPoints cut values off: the oldest values of the range are missing, so the time between before and times[0] is unknown; false when nothing was cut. Show that the start of the range is incomplete.
- Durations (time in state, uptime) over a long range or of a fast tag: use "interpolated" with a fixed maxPoints such as 2000. It covers the whole range at even steps (each value stands for one step); "recorded" keeps only the newest maxPoints values and can come back truncated. Start from before (beforeColor, beforeName) and show truncated.
- colors, names, beforeColor and beforeName: only on a source with a multistate (its payload state is not null), for example "colors": ["#30A46C", "#ff0000"], "names": ["Normal", "Error"], "beforeColor": "#30A46C", "beforeName": "Normal". colors and names are parallel to values, by the rule of state.color and state.name at each value's time (a null colour = paint nothing); beforeColor and beforeName belong to before, null without it. A source without a multistate has none of these keys. History has no blink flags.
- It rejects with an Error whose message says why: no datasources, no data yet, an invalid option or index, a PI Web API failure, a source without history, or no requested source with a value in the range or before it. Always handle the rejection (for example show error.message in the visualization).
- Request history when data first arrives, never on every data update. Keep the sourcesKey and the range of your last request. When payload.sourcesKey changed (the display switched to another asset, or a source was added, removed or reordered), request again at once and first discard the old history and everything drawn from it, so nothing of the previous sources stays visible (show "Loading..." or only the current values until the answer arrives); ignore an answer to a request made for an older sourcesKey. Otherwise request again only when the range length changed or its end moved by 60 seconds or more: on a display that ends now, timeRange moves with every update (about once a minute on a live display, a new time range at once). In between, extend the drawing to the current timeRange with the current value from vlp.onData (Examples 3 to 5). The symbol answers a repeated identical request from its previous result for up to 10 seconds.
- Ask for all the sources you need in one call. At most two requests run at a time per visualization; identical requests share one answer.
- vlp.summary(options): returns a Promise of figures per interval (an average, total, minimum or maximum per hour, shift or day) that PI calculates on the server for the symbol's own sources over the display time range (payload.timeRange); no other range. Use it for figures per interval, vlp.history for the shape of a trend. options: { sources: [0], types: ["Minimum", "Average", "Maximum"], interval: "1h" }; any other option is refused.
- sources: 0-based source indexes; default: all sources; at most 20. One index the symbol does not have rejects the whole request.
- types (required): one or more of "Average", "Minimum", "Maximum", "Range", "Total", "Count", "StdDev", "PercentGood".
- Exactly one of interval or intervals. interval: "1d" (a day), "1w" (a week from Monday) or a span that divides a day: "1h", "2h", "3h", "4h", "6h", "8h", "12h", or 1, 2, 3, 4, 5, 6, 10, 12, 15, 20 or 30 minutes ("15m") or seconds ("30s"); anything else (such as "2d", "24h", "5h", "1mo") is refused. intervals: a count n that splits the range evenly from its start in whole seconds, without calendar alignment (the last interval can end up to n - 1 seconds before the range end).
- offset (only with interval): where each day starts, a span under 24 hours such as "6h" or "30m"; interval "8h" with offset "6h" gives the shifts 06:00, 14:00 and 22:00, and "1d" with "6h" days from 06:00.
- The intervals follow the calendar of the display time zone (result.timeZone): days at midnight (plus offset), hours on the hour. The first interval starts at or before the range start, so it can begin before it; it is still a whole interval, summarised from its own start (result.timeRange.start is then before payload.timeRange.start), so label it like any other, never as partial. Where you show a figure in detail (a tooltip, a cell title), name its interval by its own start and end from result.starts and result.ends, so the viewer sees that the first interval covers its whole span although the display range starts later. Only the last one can be partial: it ends at the range end and has complete false while it runs. On a day with a daylight saving change a "1d" interval is 23 or 25 hours long and the last sub-day interval of that day absorbs the hour (hourly: 23 or 25 intervals that day; "8h": 8, 8, 7 or 8, 8, 9 hours): read starts and ends, never compute start + n x interval.
- calculationBasis: "TimeWeighted" (default: each value weighted by how long it held) or "EventWeighted" (each archived value counts once). totalPer: "s", "m", "h" or "d" (default "d"), the time unit of the rate a time-weighted Total integrates (a flow in m3/h needs "h", else the Total is 24 times too small); it is refused with "EventWeighted", whose Total is the plain sum of the values.
- At most 1000 intervals per call: a call over that is refused (never widened, which would change the meaning), so choose a longer interval.
- It resolves to { "interval": "1h", "offset": null, "calculationBasis": "TimeWeighted", "totalPer": "d", "timeZone": "Europe/Berlin", "timeRange": { "start": "2026-10-28T08:00:00.000Z", "end": "2026-10-28T10:20:00.000Z" }, "starts": ["2026-10-28T08:00:00.000Z", "2026-10-28T09:00:00.000Z", "2026-10-28T10:00:00.000Z"], "ends": ["2026-10-28T09:00:00.000Z", "2026-10-28T10:00:00.000Z", "2026-10-28T10:20:00.000Z"], "complete": [true, true, false], "sources": [{ "index": 0, "label": "Reactor temperature", "uom": "degC", "error": null, "types": { "Average": { "values": [71.9, null, 72.3], "good": [true, false, true], "uom": "degC" } } }] }. starts, ends and complete (ISO 8601 UTC) are the intervals, shared by every source; each requested type's values and good are parallel to starts. timeRange is the span actually summarised; timeZone is the IANA zone the intervals follow, null for the browser's own.
- A bad interval (no data, or not enough values) has values[i] null and good[i] false; in the same interval one type can be good and another bad. complete[i] false marks the running last interval (the range ends before its boundary, as on a display that ends now): its figures still change, so draw it as provisional (dashed or lighter).
- uom per type: Average, Minimum, Maximum, Range and StdDev in the source unit; PercentGood "%"; time-weighted Count "s"; event-weighted Count ""; Total "" in both bases (time-weighted it is the source unit times totalPer: m3/h with totalPer "h" gives m3).
- Count: time-weighted it is the interval length in seconds, with or without data, so it says nothing about the data: use PercentGood for availability. Event-weighted it is the number of archived values (0 is a good value).
- A digital, text, Boolean or time source has only Count and PercentGood (summaryTypes of each source in the live context below); asking it a numeric type makes that whole source an error with no figures at all, so ask Count or PercentGood of such a source in a call of its own. Summaries carry no state colours or names; time in state comes from interpolated history (Example 4).
- error: null, or why this source has no figures (then types is {}), for example not numeric, or "This source has no summaries in PI (not supported by its data reference)."; show it for that source. The Promise rejects with an Error whose message says why for no datasources, no data yet, an invalid option, index or interval, too many intervals or sources (or too many daylight saving changes in a long range), or when every requested source has an error. Always handle the rejection (show error.message).
- Label intervals in the display time zone, the zone they follow: vlp.formatTime formats in it (result.timeZone equals payload.locale.timeZone), while the browser's Date runs in the viewer's own zone, which can differ. For example vlp.formatTime(result.starts[i], "time"), with "date" as well when the range spans more than one day.
- Request summaries by exactly the history refresh rule above: when data first arrives; at once when payload.sourcesKey changed, after dropping the old figures and everything drawn from them, ignoring an answer to a request made for an older sourcesKey; otherwise only when the range length changed or its end moved by 60 seconds or more. In between there is nothing to extend: the running interval updates with the next request. Summaries have their own two-at-a-time limit beside history; identical requests share one answer, and a repeated identical request is answered from the previous result for up to 10 seconds.
- vlp.write(item or [items], options): writes values to the symbol's own sources and returns a Promise. It works only when payload.writeEnabled is true (the symbol's author allowed writing); otherwise show your write controls disabled.
- item: { source: 0, value: 42.5 } plus optional time (
"*"= now, the default, or an ISO 8601 time with a zone such as "2026-10-29T10:00:00Z"), mode ("replace", the default, or "noReplace": never over a value that already exists at that time, answered "conflict") and readBack (true: the item's result also carries readBack { value, time, good } read from PI after the write). Any other key is refused. source is a 0-based index of payload.sources; nothing else can be written. - value: a number, a text or true / false. A digital source takes the state name exactly as the payload shows it (for example "Auto"); a Boolean source takes true or false.
- A digital source is written by one of the state names listed, in set order, in that source's states in the live context below (the payload does not have them), spelled exactly; never guess a state name.
- An array writes up to 20 items in one call: one confirmation, all or nothing (one invalid item writes none), and every item takes the same mode.
- options is optional: { sourcesKey } pins the write to the payload you drew; by default the write carries the sourcesKey of the latest payload. Any other option is refused. A write for older sources (after an asset swap) is refused, so nothing lands on another asset.
- Call vlp.write ONLY from a click or key handler of a control the user operated. A call outside a user action (from vlp.onData, vlp.onResize, a timer or at load) is refused at once. Never write from onData, onResize or timers.
- The display asks the viewer to confirm every call in its own dialog that lists each value and time; your code cannot suppress it, so the Promise may take a while: disable the control until it settles.
- It resolves to { "ok": true, "items": [{ "source": 0, "status": "ok", "message": "", "value": 42.5, "time":
"*"}] }: one result per item, in order; ok is true only when every item is "ok"; value is the value as written, time"*"or the ISO time written. status: "ok" (written), "rejected" (an invalid value, or outside a limit), "forbidden" (the viewer has no write permission), "conflict" (noReplace and a value exists at that time), "error" (PI Web API failed), "cancelled" (the viewer declined the confirmation, or another item was rejected or in conflict) or "preview" (in the code editor's preview nothing is written). Check every item's status and show its message to the viewer; "cancelled" by the viewer is their choice, so show it neutrally, not as an error. A "cancelled" item's message says which: when another item of the same call has status "rejected" or "conflict", that item stopped the call, so show its message next to the cancelled ones; otherwise the viewer declined. Messages are complete sentences in the viewer's language (for example "Not written: the save was cancelled."): show them as they are, without a prefix of your own such as "Cancelled:". - It rejects with an Error, before anything is written, when the call never reached the write: writing not enabled, an invalid item or option, no data yet, older sources, no user action, or too many writes (one running and two waiting at a time, at most 30 calls per minute). Always handle the rejection (show error.message).
- After "ok" the new value arrives with a later payload through vlp.onData: keep showing the payload's value, not the one you sent. That payload can take several seconds, so until it comes mark the value as pending (for example "51 written, waiting for the next update") and do not reset an entry field to the old value.
- vlp.setTimeRange({ start, end }): moves the time range of the whole PI Vision display (every symbol on it) and returns a Promise. It works only when payload.timeRangeEnabled is true (the symbol's author allowed it); otherwise draw no zoom affordance. start and end: an ISO 8601 time with a zone ("2026-10-28T00:00:00Z") or epoch milliseconds; end may also be
"*"(now, a range that follows the current time); start must be before end. No other keys and no PI time expressions such as"*-8h": compute the instants yourself (from payload.timeRange, or a vlp.summary interval's starts and ends). - It resolves to { "status": "ok", "start": "2026-10-28T00:00:00.000Z", "end":
"*"} (the range requested, as ISO UTC strings) or status "preview" in the code editor's preview, where the display does not move. The new range arrives with the next payload's timeRange (and a new vlp.onData); the viewer goes back with PI Vision's time bar. When you redraw for the new range, hide an open tooltip or hover state (ECharts: chart.dispatchAction({ type: "hideTip" })): the new data has other indexes, so an entry left under the cursor would be stale. - It rejects with an Error, before anything changes, when changing the range is not enabled, for an invalid start or end, outside a user action, or for too many calls (one running and one waiting at a time, at most 20 per minute). Always handle the rejection (show error.message).
- vlp.select(name, values): publishes a selection to the other symbols of the display that listen on the Linked filter channels the symbol's author mapped to this selection name, and returns a Promise. name: a selection declared in the vlp-manifest block (see "Selections and filters" below); values: a list of up to 100 values of its dataType (texts of up to 200 characters, numbers or true / false); [] clears the selection. Publishing the values that are already selected changes nothing for the listeners.
- It resolves to { "status": "ok", "channels": 1 }: channels is the number of channels it was published on; 0 while the author has mapped none (the code works the same, nothing listens yet). In the code editor's preview status is "preview" and nothing is published.
- It rejects with an Error for a name the document does not declare, a value of the wrong type, more than 100 values, outside a user action, or for too many calls (one running and two waiting at a time, at most 60 per minute). Always handle the rejection (show error.message).
- The selection stays until your code clears it with vlp.select(name, []), it publishes another one, or the display is left.
- vlp.formatNumber(value, options): returns the number as text in the viewer's format, the one every Vision Library+ symbol on the display uses (payload.locale: decimal mark and digit grouping), for example "1,234.5" or "1.234,5". options is optional: { decimals: 1 } (exactly that many decimals), or minimumFractionDigits and maximumFractionDigits (default 0 and 3: up to three decimals, trailing zeros dropped), useGrouping (default true; false writes no group separators) and style: "decimal" (default) or "percent" (multiplies by 100 and adds the percent sign as the culture writes it: 0.125 with { style: "percent", decimals: 1 } gives "12.5 %"). Digits are whole numbers from 0 to 20; an unknown option or a bad value throws. It returns "" for null, an infinite or not-a-number value and anything that is not a number (convert a numeric string with Number() first, and show text values as they are). Synchronous: call it in vlp.onData.
- vlp.formatTime(value, style): returns a time as text in the viewer's date and time format and in the display time zone (payload.locale.timeZone), like the times of the other symbols on the display. value: an ISO 8601 string (with Z or an offset; a time without them is read as a wall-clock time of the display zone), epoch milliseconds or a Date. style: "dateTime" (the default: date and time with seconds, the display's standard format), "date", "time" (hours and minutes), "timeSeconds", or a pattern of these tokens: YYYY, YY, M, MM, MMM, MMMM (month names), D, DD, ddd, dddd (weekday names), H, HH (24-hour clock), h, hh (12-hour clock) with A or a (AM / PM), mm, ss, SSS, and [text] for literal text; every other character is written as it is. Prefer the four named styles, which follow the viewer's culture; a pattern fixes one order for every viewer. It returns "" for a value it cannot read. Synchronous.
- vlp.log(...args): writes to the editor's output console. console.log, console.info, console.warn, console.error and uncaught errors appear there too.
- vlp.echartsTheme(): registers an ECharts theme from the current theme (transparent background, theme text, axes, tooltip and font, theme.series as the series colours) and returns its name for echarts.init: "vlp-dark" or "vlp-light" for the Custom theme, "vlp-" + theme.name for a named theme; null when the document did not include ECharts. Call it when you create the chart, after the first data arrived. A chart keeps its theme: on vlp.onTheme dispose the chart and create it again. The theme already sets the colours and font of text, axes, legend, title and tooltip: do not set color or fontFamily in ECharts options. Where you do need a colour, take it from payload.theme. ECharts cannot read CSS values such as "inherit", "currentColor" or var(--vlp-...).
Each on* function may be called more than once; every handler runs. A handler that throws is reported in the console and does not stop the others.
Libraries: two libraries are bundled with the frame. Include one with exactly this script element, before your own script; a document that does not include them loads nothing extra:
- <script src="vlp:echarts"></script> loads Apache ECharts 6.0.0 (global echarts). Create a chart with echarts.init(element, vlp.echartsTheme(), { renderer: "svg" }) and call chart.resize() from vlp.onResize.
- <script src="vlp:tabulator"></script> loads Tabulator 6.3.0 (global Tabulator) together with its style sheet and rules that colour the table from the theme; include nothing else for it.
- Functions as options (formatters, tooltip formatters, event handlers) work; anything that needs eval or new Function does not. Only these two names exist: other libraries, CDN URLs and paths are blocked, and there is still no network access.
- With or without a library, CSS variables on the root element hold the current theme and follow every theme change: --vlp-foreground, --vlp-background and --vlp-accent, --vlp-surface, --vlp-surface-alternate, --vlp-surface-raised, --vlp-text-muted, --vlp-border, --vlp-divider, --vlp-font-family, --vlp-series-1 to --vlp-series-8 and --vlp-good, --vlp-warning, --vlp-bad, --vlp-neutral, for example color: var(--vlp-foreground); font-family: var(--vlp-font-family). The font Roboto, which some themes name, is available.
Parameters: settings the symbol's author sets per symbol in its configuration pane without changing the code (a title, limits, a colour), so one document can be placed many times with different settings. Declare them in ONE block in the document, which the symbol reads and never runs (Example 11):
<script type="application/json" id="vlp-manifest">
{ "parameters": [
{ "name": "title", "type": "text", "label": "Title", "default": "Line 1" },
{ "name": "high", "type": "number", "label": { "en": "High limit", "de": "Obergrenze" }, "default": 80, "min": 0, "max": 1000 },
{ "name": "showTrend", "type": "boolean", "label": "Show trend", "default": true },
{ "name": "mode", "type": "choice", "label": "Mode", "options": ["Bars", "Lines"], "default": "Bars" },
{ "name": "barColor", "type": "color", "label": "Bar colour", "default": "" } ] }
</script>
- The block is exactly <script type="application/json" id="vlp-manifest">, outside your own script (for example in <head>), and holds plain JSON: double quotes, no comments, no trailing commas. One block per document.
- name: a letter, then up to 39 letters, digits or _; unique in the block. It is the key in payload.parameters and the name the author's values are saved under.
- type: "text" (up to 500 characters), "number" (optional min, max and step), "boolean", "choice" (options: 1 to 50 different texts of up to 100 characters; the value is one of them) or "color" ("" = the theme colour, or "#rrggbb").
- label: what the author reads in the pane, a text or a map by language such as { "en": "High limit", "de": "Obergrenze" } (up to 80 characters each; the pane shows the viewer's language, else "en", else the name). default: the value until the author sets one (without it: "" for text and color, 0 moved into min and max for a number, false, the first option).
- At most 20 parameters and 16 KB. An invalid entry is skipped, not the whole block, and every problem is listed in the editor's output console when you Run: check the console after every change.
- payload.parameters holds every declared name with its current value, always of the declared type: the author's value, or the default when the author set none or the saved value no longer fits the declaration (an option you removed, a number outside a new min or max).
Selections and filters: the same block declares what the document publishes with vlp.select ("selections") and what it receives from other symbols ("filters", in payload.filters). The symbol's author maps each name to a Linked filter channel in the configuration pane, so a click on a row of your document can filter an Events Table+ or an Asset Comparison Table+, and a selection in such a symbol can filter your document (Example 12):
<script type="application/json" id="vlp-manifest">
{ "selections": [ { "name": "asset", "label": "Clicked asset", "dataType": "String" } ],
"filters": [ { "name": "asset", "label": { "en": "Asset filter", "de": "Anlagenfilter" }, "dataType": "String" } ] }
</script>
- "selections" and "filters" are lists of at most 10 entries each, beside "parameters" in the one block. name: the parameter name rule, unique within its list (a selection and a filter may share a name). label: a text or a map by language, as for parameters (default: the name). dataType: "String" (default), "Number" or "Boolean"; values are matched exactly by type and value ("12" never matches 12).
- Never rename a selection or filter when you change the document: the author's channel mapping is saved under the name. An invalid entry is skipped and listed in the editor's output console when you Run.
The payload:
{
"apiVersion": 1,
"kind": "datasources",
"sources": [
{ "index": 0, "label": "Reactor temperature", "path": "\\\\server\\Reactor1|Temperature", "value": 72.4,
"time": "2026-10-28T10:00:00.000Z", "uom": "degC", "description": "Reactor 1 temperature",
"dataType": "Float64", "good": true, "state": { "color": "#30A46C", "blink": false, "matched": true, "reason": "state", "name": "≤ 80" } }
],
"sourcesKey": "1-5b0e2c91",
"timeRange": { "start": "2026-10-28T02:00:00.000Z", "end": "2026-10-28T10:00:00.000Z" },
"size": { "width": 400, "height": 300 },
"theme": { "mode": "dark", "foreground": "#FEFEFE", "background": "#333333", "accent": "#1D68CD", "name": "PIVision",
"surface": "#333333", "surfaceAlternate": "#444444", "surfaceRaised": "#404040", "textMuted": "#D0D0D0",
"border": "#888888", "divider": "transparent", "fontFamily": "Arial, Helvetica, sans-serif",
"series": ["#1D68CD", "#F5A524", "#30A46C", "#E5484D", "#8E4EC6", "#12A594", "#D6409F", "#978365"],
"status": { "good": "#30A46C", "warning": "#F5A524", "bad": "#E5484D", "neutral": "#D0D0D0" } },
"locale": { "tag": "de-de", "language": "de", "direction": "ltr", "decimal": ",", "group": ".", "hour12": false, "timeZone": "Europe/Berlin",
"formats": { "date": "DD.MM.YYYY", "time": "HH:mm", "timeSeconds": "HH:mm:ss", "dateTime": "DD.MM.YYYY HH:mm:ss", "meridiem": ["AM", "PM"] } },
"parameters": { "title": "Line 1", "high": 80, "showTrend": true, "mode": "Bars", "barColor": "" },
"filters": { "asset": { "active": true, "values": ["Pump 1", "Pump 2"], "emptyBehavior": "showAll" } },
"writeEnabled": false,
"timeRangeEnabled": false,
"preview": false
}
Fields:
- sources: one entry per datasource of the symbol, in its order; index is the 0-based position. Pick a source by index. label, path and uom follow the display's asset: take them from the latest payload on every update, never keep the first ones.
- value: a number, a string (text, or the name of a digital state) or null.
- time: the timestamp of the value as an ISO 8601 UTC string, or null.
- good: false when the value is bad or there is no data; then value is null or not meaningful.
- uom: the unit of measure, "" when none. description, dataType and path: strings, "" when unknown.
- state: null, unless the symbol's author configured a PI Vision multi-state for this source; then the symbol has classified the current value (numbers by fixed or AF limits, digital states by their state name): { color, blink, matched, reason, name }. color is the CSS colour to paint, or null = paint nothing. blink is true when the configured state blinks: make the element blink (for example a CSS opacity animation) while it is true. matched is false when no state matched; color and blink are then the error state's. reason is "state" (matched), "noValue" (no value, or good is false) or "error" (outside every limit, an unknown digital state, or limits that could not be evaluated). name labels the state for legends: the digital state name, the AF limit name (for example "LoLo" or "Minimum") or, for fixed limits, the band ("≤ 80"; the lowest band "0 - 20" when a lower limit is set); "Error" for the error state, null for "noValue". Paint with state.color instead of thresholds of your own.
- sourcesKey: a short string that changes when the sources change (another asset on the display, a source added, removed or reordered) and never with values, times, states, size or theme. Compare it only for equality: a new sourcesKey means the history you hold belongs to other sources.
- timeRange: the display time range as ISO 8601 UTC strings (either may be null).
- size: the current frame size in CSS pixels, the space inside the container the symbol draws (also passed to vlp.onResize).
- theme: the symbol's theme, which can change at any time (vlp.onTheme). name is the theme ("Custom", "PIVision", "SteelBlue", "Light", "Transparent", a corporate theme's key, ...). mode is "dark" or "light", for the background behind your frame. foreground (text), textMuted (secondary text, axis names), accent (highlights, selection, the first series), background (the colour behind your frame: the surface, or the display background when the surface is transparent), surface, surfaceAlternate and surfaceRaised (fills for cards, rows, popups), border and divider (lines; "transparent" means draw none) are CSS colours; fontFamily is a CSS font-family. series is a list of 8 colours for categories and series, in order. status { good, warning, bad, neutral } are the colours for thresholds of your own when a source has no state.color; neutral also for no data.
- locale: how the display formats for this viewer, as PI Vision and the other symbols do; it can differ between viewers, so never assume one. tag is the culture ("de-de"), language its language ("de"; pick translated texts by it), direction "ltr" or "rtl" (Hebrew and Arabic), decimal and group the number symbols, hour12 true when times show AM / PM, timeZone the display time zone (an IANA name such as "Europe/Berlin", null for the browser's own). formats are used by vlp.formatTime: do not parse them. timeZone can change with any payload (the viewer chose another zone in PI Vision): vlp.formatNumber and vlp.formatTime always use the latest payload.
- parameters: the current value of every parameter the document declares in its vlp-manifest block, by name ({} without a block). It can change with any payload (the author edited a setting; the frame is not reloaded), so read it in every vlp.onData.
- filters: one entry per filter the document declares in its vlp-manifest block, by name ({} without one): what other symbols currently select on the channels the author mapped it to. active is true while such a selection exists; values are its values (null stands for "no value"; with several mapped channels only the values selected on all of them). active false (values []): emptyBehavior "showAll" means draw everything, "showNone" draw nothing until something is selected. It changes with any payload (a click in another symbol re-posts the payload without new data): apply it to what you draw in every vlp.onData. Your own vlp.select never reaches your own filters.
- writeEnabled: true when vlp.write may write (the symbol's author allowed writing and the site does not forbid it); when false, draw your write controls disabled. It can change with any payload.
- timeRangeEnabled: true when vlp.setTimeRange may move the display time range (the symbol's author allowed it); when false, draw no zoom affordance. It can change with any payload.
- preview: true while the code runs in the code editor's preview, false on the display. In the preview vlp.write writes nothing, vlp.setTimeRange moves nothing and vlp.select publishes nothing; each answers status "preview".
vlp.onData delivers only the current value of each source; use vlp.history for values over time and vlp.summary for figures per interval.
Examples
Example 1: Status lamp
Example 1: a status lamp coloured by the value of the first source:
<!DOCTYPE html>
<html>
<head>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; }
#wrap { display: flex; align-items: center; justify-content: center; height: 100%; }
@keyframes blink { 50% { opacity: 0.2; } }
</style>
</head>
<body>
<div id="wrap"><svg id="icon" viewBox="0 0 100 100"><circle id="lamp" cx="50" cy="50" r="40" fill="transparent"/></svg></div>
<script>
(function () {
var icon = document.getElementById("icon");
var lamp = document.getElementById("lamp");
function fit(size) {
var side = Math.max(0, Math.min(size.width, size.height) * 0.9);
icon.setAttribute("width", side);
icon.setAttribute("height", side);
}
vlp.onData(function (data) {
fit(data.size);
var source = data.sources[0];
var status = data.theme.status;
var color = status.neutral;
var blink = false;
if (source && source.state) {
// The symbol's multi-state decides; null means paint nothing.
color = source.state.color || "transparent";
blink = source.state.blink;
} else if (source && source.good && typeof source.value === "number") {
// Thresholds of its own only without a state, in the theme's status colours.
color = source.value > 80 ? status.bad : source.value > 60 ? status.warning : status.good;
}
lamp.setAttribute("fill", color);
lamp.style.animation = blink ? "blink 1s step-end infinite" : "";
});
vlp.onResize(fit);
})();
</script>
</body>
</html>
Example 2: Level bar
Example 2: a level bar for the first source (0 to 100):
<!DOCTYPE html>
<html>
<head>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; font-family: var(--vlp-font-family); }
#tank { position: absolute; top: 8px; right: 8px; bottom: 8px; left: 8px; border: 2px solid currentColor; border-radius: 6px; overflow: hidden; }
#level { position: absolute; left: 0; right: 0; bottom: 0; height: 0; }
#label { position: absolute; top: 0; right: 0; bottom: 0; left: 0; display: flex; align-items: center; justify-content: center; font-size: 18px; }
</style>
</head>
<body>
<div id="tank"><div id="level"></div><div id="label">-</div></div>
<script>
(function () {
var level = document.getElementById("level");
var label = document.getElementById("label");
var MIN = 0, MAX = 100;
function applyTheme(theme) {
document.body.style.color = theme.foreground;
level.style.backgroundColor = theme.accent;
}
vlp.onData(function (data) {
applyTheme(data.theme);
var source = data.sources[0];
if (!source || !source.good || typeof source.value !== "number") {
label.textContent = "No data";
return;
}
var percent = Math.max(0, Math.min(100, (source.value - MIN) / (MAX - MIN) * 100));
level.style.height = percent + "%";
label.textContent = vlp.formatNumber(source.value, { decimals: 1 }) + " " + source.uom;
});
vlp.onTheme(applyTheme);
})();
</script>
</body>
</html>
Example 3: Sparkline
Example 3: a sparkline of the first source over the display time range, from vlp.history:
<!DOCTYPE html>
<html>
<head>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; font-family: var(--vlp-font-family); }
#chart { position: absolute; top: 0; left: 0; }
#note { position: absolute; top: 4px; left: 6px; font-size: 12px; }
</style>
</head>
<body>
<svg id="chart"><polyline id="line" fill="none" stroke-width="2" points=""/></svg>
<div id="note"></div>
<script>
(function () {
var chart = document.getElementById("chart");
var line = document.getElementById("line");
var note = document.getElementById("note");
var size = { width: 0, height: 0 };
var range = null, series = null, current = null, asked = null;
function draw() {
chart.setAttribute("width", size.width);
chart.setAttribute("height", size.height);
if (!range) return;
var start = Date.parse(range.start), end = Date.parse(range.end), span = end - start || 1;
var times = [], values = [];
function add(time, value, good) {
var at = Date.parse(time);
if (good && typeof value === "number" && at >= start && at <= end && (!times.length || at > times[times.length - 1])) {
times.push(at);
values.push(value);
}
}
if (series) {
for (var i = 0; i < series.times.length; i++) add(series.times[i], series.values[i], series.good[i]);
// Between history requests the current value extends the line to now.
if (current) add(current.time, current.value, current.good);
}
if (values.length < 2) {
line.setAttribute("points", "");
return;
}
var low = Math.min.apply(null, values), high = Math.max.apply(null, values);
if (high === low) { high += 1; low -= 1; }
var pad = 4, points = [];
for (var j = 0; j < values.length; j++) {
var x = pad + (times[j] - start) / span * (size.width - 2 * pad);
var y = size.height - pad - (values[j] - low) / (high - low) * (size.height - 2 * pad);
points.push(x.toFixed(1) + "," + y.toFixed(1));
}
line.setAttribute("points", points.join(" "));
note.textContent = series.label + ": " + vlp.formatNumber(values[values.length - 1], { decimals: 1 }) + " " + series.uom;
}
function loadHistory() {
var key = asked.key;
vlp.history({ sources: [0], mode: "plot", maxPoints: 400 }).then(function (result) {
if (key !== asked.key) return; // asked for sources the display no longer shows
series = result.sources[0];
draw();
}, function (error) {
if (key !== asked.key) return;
series = null;
draw();
note.textContent = error.message;
});
}
function applyTheme(theme) {
document.body.style.color = theme.foreground;
line.setAttribute("stroke", theme.accent);
}
vlp.onData(function (data) {
applyTheme(data.theme);
size = data.size;
range = data.timeRange;
current = data.sources[0] || null;
var start = Date.parse(range.start), end = Date.parse(range.end);
// Other sources (an asset swap): drop the old history at once and ask again.
var other = !asked || data.sourcesKey !== asked.key;
if (other) {
series = null;
note.textContent = "Loading...";
}
// Else history when the range length changed or its end moved a minute (a live display: once a minute).
if (other || end - start !== asked.end - asked.start || Math.abs(end - asked.end) >= 60000) {
asked = { start: start, end: end, key: data.sourcesKey };
loadHistory();
}
draw();
});
vlp.onResize(function (next) { size = next; draw(); });
vlp.onTheme(applyTheme);
})();
</script>
</body>
</html>
Example 4: Time in state
Example 4: time in state of the first source over the display time range, from interpolated vlp.history with before; states by their multistate names and colours when the source has a multistate, else by value (a digital or text tag):
<!DOCTYPE html>
<html>
<head>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; font-family: var(--vlp-font-family); }
#wrap { box-sizing: border-box; height: 100%; padding: 8px; overflow: auto; }
#strip { display: flex; height: 24px; border-radius: 4px; overflow: hidden; }
#strip div { flex: none; height: 100%; }
#legend { margin-top: 8px; font-size: 12px; line-height: 18px; }
#legend i { display: inline-block; width: 10px; height: 10px; margin-right: 6px; border-radius: 2px; }
#note { margin-top: 4px; font-size: 11px; }
</style>
</head>
<body>
<div id="wrap"><div id="strip"></div><div id="legend"></div><div id="note"></div></div>
<script>
(function () {
var strip = document.getElementById("strip");
var legend = document.getElementById("legend");
var note = document.getElementById("note");
// Colours of states without a multistate: the theme's series; Unknown and No data: neutral.
var palette = [], neutral = "transparent";
var range = null, series = null, seriesStart = 0, current = null, asked = null;
function escapeHtml(text) {
return String(text).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
}
// A state: its multistate name (state.name, names) when the source has one, else the value itself;
// color false: no multistate (a colour from the palette), null: paint nothing.
function change(at, value, good, name, color) {
var state = !good || value === null ? "No data" : typeof name === "string" ? name : String(value);
return { at: at, name: state, color: color === false ? null : color || "transparent" };
}
// The changes in time order: before (Unknown without it or when truncated), the history values,
// then the current value, which extends the strip between history requests.
function changesOf() {
var list = [], b = series.before, ms = !!series.colors;
list.push(b && !series.truncated ? change(seriesStart, b.value, b.good, series.beforeName, ms && series.beforeColor)
: { at: seriesStart, name: "Unknown", color: null });
for (var i = 0; i < series.times.length; i++) {
list.push(change(Date.parse(series.times[i]), series.values[i], series.good[i], series.names ? series.names[i] : null, ms && series.colors[i]));
}
var at = current && current.time ? Date.parse(current.time) : 0;
if (at > list[list.length - 1].at) {
var s = current.state;
list.push(change(at, current.value, current.good, s ? s.name : null, !!s && s.color));
}
return list;
}
function draw() {
if (!series || !range) return;
var start = Date.parse(range.start), end = Date.parse(range.end), span = end - start;
if (!(span > 0)) return;
var list = changesOf();
var totals = {}, order = [], colors = {}, used = 0, bar = "", html = "";
for (var i = 0; i < list.length; i++) {
// Each state holds until the next change (the last one until the end), clipped to the range.
var from = Math.max(list[i].at, start), to = Math.min(i + 1 < list.length ? list[i + 1].at : end, end);
if (!(to > from)) continue;
var name = list[i].name;
if (!Object.prototype.hasOwnProperty.call(totals, name)) {
totals[name] = 0;
order.push(name);
colors[name] = list[i].color || (name === "Unknown" || name === "No data" ? neutral : palette[used++ % palette.length]);
}
totals[name] += to - from;
bar += "<div style='width:" + ((to - from) / span * 100).toFixed(3) + "%;background:" + colors[name] + "' title='" + escapeHtml(name) + "'></div>";
}
for (var j = 0; j < order.length; j++) {
html += "<div><i style='background:" + colors[order[j]] + "'></i>" + escapeHtml(order[j]) + ": " + vlp.formatNumber(totals[order[j]] / span, { style: "percent", decimals: 1 }) + "</div>";
}
strip.innerHTML = bar;
legend.innerHTML = html;
note.textContent = series.truncated ? "More values than maxPoints: the start of the range is Unknown." : "";
}
function loadHistory() {
var start = asked.start, key = asked.key;
// Interpolated covers the whole range at even steps; recorded could be cut off by maxPoints.
vlp.history({ sources: [0], mode: "interpolated", maxPoints: 2000 }).then(function (result) {
if (key !== asked.key) return; // asked for sources the display no longer shows
series = result.sources[0];
seriesStart = start;
draw();
}, function (error) {
if (key !== asked.key) return;
series = null;
strip.innerHTML = "";
legend.innerHTML = "";
note.textContent = error.message;
});
}
function applyTheme(theme) {
document.body.style.color = theme.foreground;
palette = theme.series;
neutral = theme.status.neutral;
}
vlp.onData(function (data) {
applyTheme(data.theme);
range = data.timeRange;
current = data.sources[0] || null;
var start = Date.parse(range.start), end = Date.parse(range.end);
// Other sources (an asset swap): drop the old history at once and ask again.
var other = !asked || data.sourcesKey !== asked.key;
if (other) {
series = null;
strip.innerHTML = "";
legend.innerHTML = "";
note.textContent = "Loading...";
}
// Else history when the range length changed or its end moved a minute (a live display: once a minute).
if (other || end - start !== asked.end - asked.start || Math.abs(end - asked.end) >= 60000) {
asked = { start: start, end: end, key: data.sourcesKey };
loadHistory();
}
draw();
});
vlp.onTheme(function (theme) {
applyTheme(theme);
draw();
});
})();
</script>
</body>
</html>
Example 5: ECharts line chart
Example 5: an ECharts line chart of the first source over the display time range, from vlp.history (points in the multistate colours when the source has them):
<!DOCTYPE html>
<html>
<head>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; font-family: var(--vlp-font-family); }
#chart { position: absolute; top: 0; right: 0; bottom: 0; left: 0; }
#note { position: absolute; top: 4px; left: 8px; font-size: 12px; }
</style>
<script src="vlp:echarts"></script>
</head>
<body>
<div id="chart"></div>
<div id="note"></div>
<script>
(function () {
var element = document.getElementById("chart");
var note = document.getElementById("note");
var chart = null;
var range = null, series = null, current = null, asked = null;
function point(time, value, good, color) {
var y = good && typeof value === "number" ? value : null;
return color ? { value: [time, y], itemStyle: { color: color } } : [time, y];
}
function escapeHtml(text) {
return String(text).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
}
// Axis labels and the tooltip in the display time zone and the viewer's format (ECharts would use
// the browser's zone); over more than a day with the date.
function timeText(value) {
var text = vlp.formatTime(value, "time");
return Date.parse(range.end) - Date.parse(range.start) > 86400000 ? vlp.formatTime(value, "date") + " " + text : text;
}
function tooltipText(params) {
var point = params[0];
if (!point) return "";
var y = point.value[1];
return escapeHtml(timeText(point.value[0])) + "<br>" + escapeHtml(point.seriesName) + ": "
+ (y === null ? "No data" : escapeHtml(vlp.formatNumber(y) + " " + (series ? series.uom : "")));
}
function render() {
if (!chart || !range) return;
var points = [];
if (series) {
for (var i = 0; i < series.times.length; i++) {
points.push(point(series.times[i], series.values[i], series.good[i], series.colors ? series.colors[i] : null));
}
// Between history requests the current value extends the line to now.
var last = series.times.length ? Date.parse(series.times[series.times.length - 1]) : 0;
if (current && current.time && Date.parse(current.time) > last) {
points.push(point(current.time, current.value, current.good, current.state ? current.state.color : null));
}
}
chart.setOption({
animation: false,
grid: { left: 56, right: 16, top: 28, bottom: 28 },
tooltip: { trigger: "axis", formatter: tooltipText },
xAxis: { type: "time", min: range.start, max: range.end, axisLabel: { formatter: timeText } },
yAxis: { type: "value", scale: true, name: series ? series.uom : "", axisLabel: { formatter: function (value) { return vlp.formatNumber(value); } } },
series: [{ type: "line", name: series ? series.label : "", showSymbol: !!(series && series.colors), data: points }]
});
}
function createChart() {
if (chart) chart.dispose();
chart = echarts.init(element, vlp.echartsTheme(), { renderer: "svg" });
render();
}
function loadHistory() {
var key = asked.key;
vlp.history({ sources: [0], mode: "plot", maxPoints: 500 }).then(function (result) {
if (key !== asked.key) return; // asked for sources the display no longer shows
series = result.sources[0];
note.textContent = "";
render();
}, function (error) {
if (key !== asked.key) return;
series = null;
render();
note.textContent = error.message;
});
}
vlp.onData(function (data) {
note.style.color = data.theme.foreground;
range = data.timeRange;
current = data.sources[0] || null;
var start = Date.parse(range.start), end = Date.parse(range.end);
// Other sources (an asset swap): drop the old history at once and ask again.
var other = !asked || data.sourcesKey !== asked.key;
if (other) {
series = null;
note.textContent = "Loading...";
}
// Else history when the range length changed or its end moved a minute (a live display: once a minute).
if (other || end - start !== asked.end - asked.start || Math.abs(end - asked.end) >= 60000) {
asked = { start: start, end: end, key: data.sourcesKey };
loadHistory();
}
if (chart) render(); else createChart();
});
vlp.onResize(function () { if (chart) chart.resize(); });
vlp.onTheme(function (theme) {
note.style.color = theme.foreground;
createChart();
});
})();
</script>
</body>
</html>
Example 6: Tabulator table
Example 6: a Tabulator table of the current values, each value marked in its multistate colour:
<!DOCTYPE html>
<html>
<head>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; font-family: var(--vlp-font-family); }
#table { height: 100%; }
</style>
<script src="vlp:tabulator"></script>
</head>
<body>
<div id="table"></div>
<script>
(function () {
var table = null;
var built = false;
var pending = null;
function escapeHtml(text) {
return String(text).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
}
function rowsOf(data) {
return data.sources.map(function (source) {
return {
index: source.index,
label: source.label,
value: !source.good || source.value === null ? "No data" : typeof source.value === "number" ? vlp.formatNumber(source.value) : source.value,
uom: source.uom,
time: vlp.formatTime(source.time),
color: source.state ? source.state.color : null
};
});
}
// The value cell gets a bar in the state colour; a formatter returns HTML, so the text is escaped.
function valueCell(cell) {
var color = cell.getRow().getData().color;
cell.getElement().style.borderLeft = "4px solid " + (color || "transparent");
return escapeHtml(cell.getValue());
}
vlp.onData(function (data) {
var rows = rowsOf(data);
if (!table) {
table = new Tabulator("#table", {
index: "index",
data: rows,
layout: "fitColumns",
placeholder: "No datasources",
columns: [
{ title: "Source", field: "label" },
{ title: "Value", field: "value", hozAlign: "right", formatter: valueCell },
{ title: "Units", field: "uom", width: 80 },
{ title: "Time", field: "time" }
]
});
// Data that arrives while the table is still being built waits for it.
table.on("tableBuilt", function () {
built = true;
if (pending) table.replaceData(pending);
pending = null;
});
} else if (built) {
table.replaceData(rows);
} else {
pending = rows;
}
});
vlp.onResize(function () { if (built) table.redraw(); });
})();
</script>
</body>
</html>
Example 7: Set point entry
Example 7: a set point entry for the first source (a number): type a value, press Write, see the result; the new value shows when the next payload brings it:
<!DOCTYPE html>
<html>
<head>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; font-family: var(--vlp-font-family); }
#wrap { box-sizing: border-box; height: 100%; padding: 8px; font-size: 13px; }
#row { display: flex; gap: 6px; margin: 6px 0; }
#entry { flex: 1; min-width: 0; font: inherit; }
#send { font: inherit; padding: 2px 12px; }
#hint, #result { font-size: 12px; }
input:disabled, button:disabled { opacity: 0.5; }
</style>
</head>
<body>
<div id="wrap">
<div id="current">-</div>
<div id="row"><input id="entry" type="number" step="any"><button id="send" type="button">Write</button></div>
<div id="hint"></div>
<div id="result"></div>
</div>
<script>
(function () {
var current = document.getElementById("current");
var entry = document.getElementById("entry");
var send = document.getElementById("send");
var hint = document.getElementById("hint");
var result = document.getElementById("result");
var latest = null, busy = false;
// writeEnabled false (or no source): the controls stay visible but disabled.
function update() {
var enabled = !!latest && latest.writeEnabled === true && !!latest.sources[0];
entry.disabled = !enabled;
send.disabled = busy || !enabled;
hint.textContent = enabled ? "" : "Writing is not enabled for this symbol.";
}
function describe(item) {
if (item.status === "ok") return "Written: " + vlp.formatNumber(item.value) + ". The display shows it with the next update.";
// Any other status: the message is a complete sentence, shown as it is.
return item.message;
}
vlp.onData(function (data) {
latest = data;
document.body.style.color = data.theme.foreground;
var source = data.sources[0];
// The shown value always comes from the payload, also right after a write.
current.textContent = !source ? "No source" : source.label + ": "
+ (source.good && typeof source.value === "number" ? vlp.formatNumber(source.value) + " " + source.uom : "No data");
update();
});
vlp.onTheme(function (theme) { document.body.style.color = theme.foreground; });
// Only a click writes: vlp.write refuses a call outside a user action.
send.addEventListener("click", function () {
var value = Number(entry.value);
if (entry.value === "" || !isFinite(value)) {
result.textContent = "Enter a number.";
return;
}
busy = true;
update();
result.textContent = "Writing...";
// The display asks the viewer to confirm before anything is written.
vlp.write({ source: 0, value: value }).then(function (answer) {
result.textContent = describe(answer.items[0]);
}, function (error) {
result.textContent = error.message;
}).then(function () {
busy = false;
update();
});
});
})();
</script>
</body>
</html>
Example 8: Auto / Manual switch
Example 8: an Auto / Manual switch for the first source (a digital state): a button per state name, the current state highlighted, a press writes that state:
<!DOCTYPE html>
<html>
<head>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; font-family: var(--vlp-font-family); }
#wrap { box-sizing: border-box; height: 100%; padding: 8px; font-size: 13px; }
#buttons { display: flex; gap: 6px; margin: 6px 0; }
#buttons button { flex: 1; font: inherit; padding: 4px; color: inherit; background: transparent; border: 1px solid currentColor; border-radius: 4px; }
#buttons button.on { background: color-mix(in srgb, var(--vlp-accent) 35%, transparent); border-color: var(--vlp-accent); }
#buttons button:disabled { opacity: 0.5; }
#hint, #result { font-size: 12px; }
</style>
</head>
<body>
<div id="wrap">
<div id="label">-</div>
<div id="buttons"><button id="auto" type="button">Auto</button><button id="manual" type="button">Manual</button></div>
<div id="hint"></div>
<div id="result"></div>
</div>
<script>
(function () {
var label = document.getElementById("label");
var hint = document.getElementById("hint");
var result = document.getElementById("result");
// A digital source is written with its state name, exactly as the payload shows it.
var choices = [
{ element: document.getElementById("auto"), state: "Auto" },
{ element: document.getElementById("manual"), state: "Manual" }
];
var latest = null, busy = false;
function update() {
var source = latest ? latest.sources[0] : null;
var enabled = !!source && latest.writeEnabled === true;
choices.forEach(function (choice) {
choice.element.className = source && source.good && source.value === choice.state ? "on" : "";
choice.element.disabled = busy || !enabled;
});
hint.textContent = enabled ? "" : "Writing is not enabled for this symbol.";
}
function choose(state) {
busy = true;
update();
result.textContent = "Switching to " + state + "...";
vlp.write({ source: 0, value: state }).then(function (answer) {
var item = answer.items[0];
result.textContent = item.status === "ok" ? "" : item.message;
}, function (error) {
result.textContent = error.message;
}).then(function () {
busy = false;
update();
});
}
vlp.onData(function (data) {
latest = data;
document.body.style.color = data.theme.foreground;
var source = data.sources[0];
// The highlight follows the payload: after a write it moves with the next update.
label.textContent = !source ? "No source" : source.label + ": " + (source.good ? source.value : "No data");
update();
});
vlp.onTheme(function (theme) { document.body.style.color = theme.foreground; });
// Only a click writes: vlp.write refuses a call outside a user action.
choices.forEach(function (choice) {
choice.element.addEventListener("click", function () { choose(choice.state); });
});
})();
</script>
</body>
</html>
Example 9: ECharts hourly band
Example 9: an ECharts band of the hourly minimum and maximum of the first source with its hourly average, from vlp.summary over the display time range (the running hour drawn dashed and lighter, labels in the display time zone):
<!DOCTYPE html>
<html>
<head>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; font-family: var(--vlp-font-family); }
#chart { position: absolute; top: 0; right: 0; bottom: 0; left: 0; }
#note { position: absolute; top: 4px; left: 8px; font-size: 12px; }
</style>
<script src="vlp:echarts"></script>
</head>
<body>
<div id="chart"></div>
<div id="note"></div>
<script>
(function () {
var element = document.getElementById("chart");
var note = document.getElementById("note");
var chart = null, foreground = "";
var label = "", summary = null, failure = "", asked = null, drawn = null;
// Interval labels in the display zone, the zone the intervals follow (vlp.formatTime).
// Over more than a day an hour alone is ambiguous, so the label carries the date too.
function labelOf(time, withDate) {
return withDate ? vlp.formatTime(time, "date") + " " + vlp.formatTime(time, "time") : vlp.formatTime(time, "time");
}
function figure(type, i) {
var item = summary.sources[0].types[type];
return item.good[i] ? vlp.formatNumber(item.values[i], { decimals: 2 }) : "no data";
}
// Per interval: a transparent bar up to the minimum, a bar from the minimum to the maximum on top,
// the average as a line. The running hour (complete false) is lighter, its average dashed.
function rowsOf() {
var rows = { labels: [], base: [], band: [], average: [], running: [], tips: [], uom: "" };
var source = summary ? summary.sources[0] : null;
if (!source || source.error) return rows;
var min = source.types.Minimum, avg = source.types.Average, max = source.types.Maximum;
var span = Date.parse(summary.timeRange.end) - Date.parse(summary.timeRange.start);
var withDate = span > 86400000, count = summary.starts.length;
rows.uom = avg.uom;
for (var i = 0; i < count; i++) {
var done = summary.complete[i], both = min.good[i] && max.good[i];
var mean = avg.good[i] ? avg.values[i] : null;
var text = labelOf(summary.starts[i], withDate);
rows.labels.push(text);
rows.base.push(both ? min.values[i] : null);
rows.band.push(both ? { value: max.values[i] - min.values[i], itemStyle: done ? {} : { opacity: 0.35, borderType: "dashed", borderWidth: 1 } } : null);
rows.average.push(done ? mean : null);
// The dashed line runs from the last complete average into the running interval.
rows.running.push(!done || (i + 1 < count && !summary.complete[i + 1]) ? mean : null);
// The tooltip names the interval by its own start and end (the first can start before the range).
rows.tips.push(text + " - " + labelOf(summary.ends[i], withDate) + (done ? "" : " (running, provisional)") + "<br>Maximum " + figure("Maximum", i)
+ "<br>Average " + figure("Average", i) + "<br>Minimum " + figure("Minimum", i));
}
return rows;
}
function render() {
if (!chart) return;
var rows = rowsOf(), source = summary ? summary.sources[0] : null;
drawn = rows;
if (failure) note.textContent = failure;
else if (!source) note.textContent = label + ": Loading...";
else if (source.error) note.textContent = label + ": " + source.error;
else note.textContent = label + ": hourly minimum, average and maximum";
chart.setOption({
animation: false,
grid: { left: 56, right: 16, top: 44, bottom: 28 },
tooltip: { trigger: "axis", formatter: function (params) { return drawn.tips[params[0].dataIndex] || ""; } },
xAxis: { type: "category", data: rows.labels },
yAxis: { type: "value", scale: true, name: rows.uom, axisLabel: { formatter: function (value) { return vlp.formatNumber(value); } } },
series: [
{ type: "bar", stack: "band", silent: true, itemStyle: { color: "transparent" }, data: rows.base },
{ type: "bar", stack: "band", name: "Minimum - maximum", itemStyle: { opacity: 0.6 }, data: rows.band },
{ type: "line", name: "Average", itemStyle: { color: foreground }, lineStyle: { color: foreground }, data: rows.average },
{ type: "line", name: "Average (running)", itemStyle: { color: foreground }, lineStyle: { color: foreground, type: "dashed" }, data: rows.running }
]
});
}
function createChart() {
if (chart) chart.dispose();
chart = echarts.init(element, vlp.echartsTheme(), { renderer: "svg" });
render();
}
function loadSummary() {
var key = asked.key;
vlp.summary({ sources: [0], types: ["Minimum", "Average", "Maximum"], interval: "1h" }).then(function (result) {
if (key !== asked.key) return; // asked for sources the display no longer shows
summary = result;
failure = "";
render();
}, function (error) {
if (key !== asked.key) return;
summary = null;
failure = error.message;
render();
});
}
vlp.onData(function (data) {
foreground = data.theme.foreground;
note.style.color = foreground;
label = data.sources[0] ? data.sources[0].label : "";
var start = Date.parse(data.timeRange.start), end = Date.parse(data.timeRange.end);
// Other sources (an asset swap): drop the old figures at once and ask again.
var other = !asked || data.sourcesKey !== asked.key;
if (other) {
summary = null;
failure = "";
}
// Else when the range length changed or its end moved a minute (a live display: once a minute,
// which also updates the running hour).
if (other || end - start !== asked.end - asked.start || Math.abs(end - asked.end) >= 60000) {
asked = { start: start, end: end, key: data.sourcesKey };
loadSummary();
}
// Redraw only when something shown changed: new sources here, new figures in loadSummary.
if (!chart) createChart(); else if (other) render();
});
vlp.onResize(function () { if (chart) chart.resize(); });
vlp.onTheme(function (theme) {
foreground = theme.foreground;
note.style.color = foreground;
createChart();
});
})();
</script>
</body>
</html>
Example 10: KPI list in the viewer's format
Example 10: a KPI list in the viewer's format: each value through vlp.formatNumber, its time through vlp.formatTime, the captions in English or German by payload.locale.language:
<!DOCTYPE html>
<html>
<head>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; font-family: var(--vlp-font-family); color: var(--vlp-foreground); }
#wrap { box-sizing: border-box; height: 100%; padding: 8px; overflow: auto; }
#title { margin-bottom: 4px; font-size: 12px; color: var(--vlp-text-muted); }
.kpi { display: grid; grid-template-columns: 1fr auto; gap: 0 8px; padding: 6px 0; border-bottom: 1px solid var(--vlp-divider); }
.label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.value { font-size: 18px; text-align: end; font-variant-numeric: tabular-nums; }
.time { grid-column: 1 / 3; font-size: 11px; color: var(--vlp-text-muted); }
</style>
</head>
<body>
<div id="wrap"><div id="title"></div><div id="list"></div></div>
<script>
(function () {
var wrap = document.getElementById("wrap");
var title = document.getElementById("title");
var list = document.getElementById("list");
// The captions in English, and in German for a German viewer; every other language gets English.
var TEXTS = {
en: { title: "Current values", noData: "No data", noSources: "No datasources" },
de: { title: "Aktuelle Werte", noData: "Keine Daten", noSources: "Keine Datenquellen" }
};
// A pool of rows that grows with the sources and hides the unused ones.
var pool = [];
function part(row, name) {
var element = document.createElement("div");
element.className = name;
row.appendChild(element);
return element;
}
function rowAt(i) {
if (!pool[i]) {
var row = document.createElement("div");
row.className = "kpi";
list.appendChild(row);
pool[i] = { row: row, label: part(row, "label"), value: part(row, "value"), time: part(row, "time") };
}
return pool[i];
}
// A number in the viewer's format with up to two decimals; a text value (a digital state) as it is.
function valueText(source, texts) {
if (!source.good || source.value === null) return texts.noData;
var text = typeof source.value === "number" ? vlp.formatNumber(source.value, { maximumFractionDigits: 2 }) : String(source.value);
return source.uom ? text + " " + source.uom : text;
}
vlp.onData(function (data) {
var language = data.locale.language;
var texts = TEXTS[language] || TEXTS.en;
// The direction of the language the texts are in (both of these are left to right).
wrap.dir = TEXTS[language] ? data.locale.direction : "ltr";
title.textContent = data.sources.length ? texts.title : texts.noSources;
var count = Math.max(pool.length, data.sources.length);
for (var i = 0; i < count; i++) {
var source = data.sources[i];
var row = rowAt(i);
row.row.style.display = source ? "" : "none";
if (!source) continue;
row.label.textContent = source.label;
row.value.textContent = valueText(source, texts);
// The value's own time, in the display time zone and the viewer's date order.
row.time.textContent = vlp.formatTime(source.time);
}
});
})();
</script>
</body>
</html>
Example 11: Gauge with adjustable settings
Example 11: a gauge of the first source whose title, low and high limits, decimals and colour the symbol's author sets per symbol (parameters):
<!DOCTYPE html>
<html>
<head>
<script type="application/json" id="vlp-manifest">
{ "parameters": [
{ "name": "title", "type": "text", "label": { "en": "Title", "de": "Titel" }, "default": "" },
{ "name": "low", "type": "number", "label": { "en": "Low limit", "de": "Untere Grenze" }, "default": 0 },
{ "name": "high", "type": "number", "label": { "en": "High limit", "de": "Obere Grenze" }, "default": 100 },
{ "name": "decimals", "type": "number", "label": { "en": "Decimals", "de": "Nachkommastellen" }, "default": 1, "min": 0, "max": 6, "step": 1 },
{ "name": "color", "type": "color", "label": { "en": "Gauge colour", "de": "Anzeigefarbe" }, "default": "" } ] }
</script>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; font-family: var(--vlp-font-family); color: var(--vlp-foreground); }
#wrap { box-sizing: border-box; height: 100%; padding: 8px; display: flex; flex-direction: column; }
#title { font-size: 13px; text-align: center; color: var(--vlp-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
svg { flex: 1; min-height: 0; width: 100%; }
#track { fill: none; stroke: color-mix(in srgb, var(--vlp-foreground) 15%, transparent); stroke-width: 16; }
#arc { fill: none; stroke-width: 16; }
#reading { font-size: 24px; text-anchor: middle; fill: var(--vlp-foreground); }
.scale { font-size: 11px; fill: var(--vlp-text-muted); }
#note { min-height: 14px; font-size: 11px; text-align: center; color: var(--vlp-text-muted); }
</style>
</head>
<body>
<div id="wrap">
<div id="title"></div>
<svg viewBox="0 0 200 120" preserveAspectRatio="xMidYMid meet">
<path id="track" d="M 20 100 A 80 80 0 0 1 180 100"></path>
<path id="arc" d=""></path>
<text id="reading" x="100" y="92"></text>
<text id="lowLabel" class="scale" x="20" y="116" text-anchor="middle"></text>
<text id="highLabel" class="scale" x="180" y="116" text-anchor="middle"></text>
</svg>
<div id="note"></div>
</div>
<script>
(function () {
var title = document.getElementById("title");
var arc = document.getElementById("arc");
var reading = document.getElementById("reading");
var lowLabel = document.getElementById("lowLabel");
var highLabel = document.getElementById("highLabel");
var note = document.getElementById("note");
// An author setting from the vlp-manifest block above. The symbol always sends every declared one;
// the fallback only keeps the document working outside the symbol.
function setting(parameters, name, fallback) {
return parameters && name in parameters ? parameters[name] : fallback;
}
// The end of the arc for a fraction from 0 to 1 of the half circle (plain numbers: SVG coordinates).
function arcPath(fraction) {
if (!(fraction > 0)) return "";
var angle = Math.PI * (1 - Math.min(fraction, 1));
var x = Math.round((100 + 80 * Math.cos(angle)) * 100) / 100;
var y = Math.round((100 - 80 * Math.sin(angle)) * 100) / 100;
return "M 20 100 A 80 80 0 0 1 " + x + " " + y;
}
vlp.onData(function (data) {
var parameters = data.parameters;
var low = setting(parameters, "low", 0);
var high = setting(parameters, "high", 100);
var decimals = Math.round(setting(parameters, "decimals", 1));
var color = setting(parameters, "color", "");
var source = data.sources[0];
// The title the author set (shown as text, never as HTML), else the source label.
title.textContent = setting(parameters, "title", "") || (source ? source.label : "");
lowLabel.textContent = vlp.formatNumber(low);
highLabel.textContent = vlp.formatNumber(high);
// A multi-state colour first, then the author's colour ("" = the theme accent).
var stroke = source && source.state ? (source.state.color || "transparent") : (color || data.theme.accent);
arc.setAttribute("stroke", stroke);
reading.setAttribute("fill", data.theme.foreground);
note.textContent = "";
if (!(low < high)) {
arc.setAttribute("d", "");
reading.textContent = "-";
note.textContent = "Set the low limit below the high limit.";
return;
}
if (!source || !source.good || typeof source.value !== "number") {
arc.setAttribute("d", "");
reading.textContent = source && source.good && source.value !== null ? String(source.value) : "No data";
return;
}
arc.setAttribute("d", arcPath((source.value - low) / (high - low)));
reading.textContent = vlp.formatNumber(source.value, { decimals: decimals }) + (source.uom ? " " + source.uom : "");
if (source.value < low || source.value > high) {
reading.setAttribute("fill", data.theme.status.warning);
note.textContent = source.value < low ? "Below the low limit" : "Above the high limit";
}
});
})();
</script>
</body>
</html>
Example 12: Daily bars with click and filter
Example 12: daily average bars of every source from vlp.summary; a click on a day moves the display to that day (vlp.setTimeRange, when allowed) and selects that source by its label (vlp.select), sources selected in other symbols filter the rows (payload.filters):
<!DOCTYPE html>
<html>
<head>
<script type="application/json" id="vlp-manifest">
{ "selections": [ { "name": "source", "label": { "en": "Clicked source", "de": "Angeklickte Quelle" }, "dataType": "String" } ],
"filters": [ { "name": "source", "label": { "en": "Shown sources", "de": "Angezeigte Quellen" }, "dataType": "String" } ] }
</script>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; font-family: var(--vlp-font-family); color: var(--vlp-foreground); }
#wrap { box-sizing: border-box; height: 100%; padding: 8px; display: flex; flex-direction: column; }
#bar { display: flex; align-items: center; gap: 8px; min-height: 24px; font-size: 12px; color: var(--vlp-text-muted); }
#note { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#clear { font: inherit; color: var(--vlp-foreground); background: transparent; border: 1px solid var(--vlp-border); border-radius: 3px; padding: 1px 8px; cursor: pointer; }
#rows { flex: 1; min-height: 0; overflow-y: auto; }
.row { display: grid; grid-template-columns: minmax(60px, 30%) 1fr; gap: 8px; align-items: end; padding: 4px 4px 4px 6px; border-left: 3px solid transparent; }
.row.selected { border-left-color: var(--vlp-accent); }
.label { align-self: center; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.days { display: flex; align-items: flex-end; gap: 3px; height: 40px; }
.day { flex: 1; min-width: 4px; background: var(--vlp-series-1); cursor: pointer; }
.day.zoom { cursor: zoom-in; }
.day.running { opacity: 0.45; }
.day.empty { background: var(--vlp-neutral); opacity: 0.35; }
</style>
</head>
<body>
<div id="wrap">
<div id="bar"><span id="note"></span><button id="clear" type="button">Clear selection</button></div>
<div id="rows"></div>
</div>
<script>
(function () {
var note = document.getElementById("note");
var clear = document.getElementById("clear");
var list = document.getElementById("rows");
// A pool of rows (one per source) that grows and hides the unused ones; each row keeps a pool of day bars.
var pool = [];
var latest = null, summary = null, failure = "", message = "", asked = null, selected = null;
function part(parent, tag, name) {
var element = document.createElement(tag);
element.className = name;
parent.appendChild(element);
return element;
}
function rowAt(i) {
if (!pool[i]) {
var row = part(list, "div", "row");
pool[i] = { row: row, label: part(row, "div", "label"), days: part(row, "div", "days"), bars: [], source: null };
}
return pool[i];
}
function barAt(entry, d) {
if (!entry.bars[d]) {
var bar = part(entry.days, "div", "day");
bar.addEventListener("click", function () { pick(entry, d); });
entry.bars[d] = bar;
}
return entry.bars[d];
}
// The daily averages of one source, null while there are none (loading, or the source has an error).
function averagesOf(source) {
var found = summary ? summary.sources.filter(function (item) { return item.index === source.index; })[0] : null;
return found && !found.error ? found.types.Average : null;
}
// payload.filters.source: the sources other symbols selected (by label) on the channels the author mapped.
function shows(source) {
var filter = latest.filters && latest.filters.source;
if (!filter) return true;
if (!filter.active) return filter.emptyBehavior !== "showNone";
return filter.values.indexOf(source.label) >= 0;
}
function show(error) {
message = error.message;
render();
}
// A click on a day: the display moves to that day (only when the author allowed it; the running day
// up to now) and the source is selected on the channels the author mapped. Called only from a click.
function pick(entry, d) {
if (!summary || !entry.source) return;
message = "";
if (latest.timeRangeEnabled) {
vlp.setTimeRange({ start: summary.starts[d], end: summary.complete[d] ? summary.ends[d] : "*" }).catch(show);
}
var label = entry.source.label;
// A click on the source already selected clears the selection.
var values = label === selected ? [] : [label];
vlp.select("source", values).then(function () {
selected = values.length ? label : null;
render();
}, show);
}
// The visible way back: clears the selection on every mapped channel.
clear.addEventListener("click", function () {
message = "";
vlp.select("source", []).then(function () {
selected = null;
render();
}, show);
});
// A day boundary in the display zone, with its date (the range spans more than one day).
function at(time) {
return vlp.formatTime(time, "date") + " " + vlp.formatTime(time, "time");
}
// One bar per day, its height the share of the largest average shown; the running day lighter.
function drawDays(entry, averages, uom, max) {
var days = averages ? summary.starts.length : 0;
var zoom = latest.timeRangeEnabled ? " zoom" : "";
var count = Math.max(days, entry.bars.length);
for (var d = 0; d < count; d++) {
var bar = barAt(entry, d);
if (d >= days) {
bar.style.display = "none";
bar.setAttribute("title", "");
continue;
}
var good = averages.good[d];
var running = !summary.complete[d];
var height = good && max > 0 ? Math.max(4, Math.round(averages.values[d] / max * 100)) : 4;
bar.style.display = "";
bar.style.height = height + "%";
bar.className = "day" + zoom + (good ? "" : " empty") + (running ? " running" : "");
// The title names the day by its own start and end (the first can start before the range).
bar.setAttribute("title", at(summary.starts[d]) + " - " + at(summary.ends[d]) + ": "
+ (good ? vlp.formatNumber(averages.values[d], { maximumFractionDigits: 1 }) + (uom ? " " + uom : "") : "no data")
+ (running ? " (running)" : ""));
}
}
function render() {
var shown = latest.sources.filter(shows);
var max = 0;
shown.forEach(function (source) {
var averages = averagesOf(source);
if (averages) averages.values.forEach(function (value, d) { if (averages.good[d] && value > max) max = value; });
});
var hint = latest.timeRangeEnabled ? "Daily averages: click a day to show it and select its source" : "Daily averages: click a day to select its source";
note.textContent = failure || message || (!latest.sources.length ? "No datasources" : !summary ? "Loading..."
: shown.length ? hint : "No source matches the filter");
clear.style.display = selected !== null ? "" : "none";
var count = Math.max(pool.length, latest.sources.length);
for (var i = 0; i < count; i++) {
var source = latest.sources[i];
var entry = rowAt(i);
var visible = !!source && shows(source);
entry.row.style.display = visible ? "" : "none";
entry.source = visible ? source : null;
if (!visible) continue;
entry.row.className = source.label === selected ? "row selected" : "row";
entry.label.textContent = source.label;
drawDays(entry, averagesOf(source), source.uom, max);
}
}
// Daily averages of up to 20 sources (the vlp.summary limit) over the display time range.
function load() {
var key = asked.key;
var indexes = latest.sources.slice(0, 20).map(function (source) { return source.index; });
if (!indexes.length) return;
vlp.summary({ sources: indexes, types: ["Average"], interval: "1d" }).then(function (result) {
if (key !== asked.key) return; // asked for sources the display no longer shows
summary = result;
failure = "";
render();
}, function (error) {
if (key !== asked.key) return;
summary = null;
failure = error.message;
render();
});
}
vlp.onData(function (data) {
latest = data;
var start = Date.parse(data.timeRange.start), end = Date.parse(data.timeRange.end);
// Other sources (an asset swap): drop the old figures at once and ask again.
var other = !asked || data.sourcesKey !== asked.key;
if (other) {
summary = null;
failure = "";
}
// Else when the range length changed or its end moved a minute (a live display: once a minute).
if (other || end - start !== asked.end - asked.start || Math.abs(end - asked.end) >= 60000) {
asked = { start: start, end: end, key: data.sourcesKey };
load();
}
// Labels, filters and timeRangeEnabled can change with any payload.
render();
});
})();
</script>
</body>
</html>
