This is the practical companion to Widgets. Read that first for the concepts;
this is the code.
Save as /js/widgets/heading.js:
import { Widget } from "/js/widgets/widgets.js";
export default class Heading extends Widget {
constructor(options = {}) {
super(options);
let sourceID = options.id != null ? options.id : "";
this.Name = "Heading: #" + sourceID;
this.parentID = "#" + sourceID;
this.Parent = document.getElementById(sourceID);
this.Subscribe("reset", (m) => { this.Render(); });
this.Subscribe("vessel", (m) => { this.Render(); });
}
Render() {
SetHtml(this.parentID, thisvessel.HeadingVerbose);
}
}
Declare it in a console page:
<hz-widget id="heading" source="heading" class="panel-header"></hz-widget>
That is the whole contract. The default export is the constructor, the source
attribute names the file, and options carries every attribute from the
declaration.
Anatomy
super(options) first. Pass the options through — the
base needs them, and it registers the widget as under construction so a later throw
can still be cleaned up.
this.Name is how
workbench.Widgets.Find() will locate this instance. Include the element id;
that is the convention and it makes duplicates visible.
this.Parent is the element to render into. The
controller has already created it by the time the constructor runs.
this.Subscribe, not game.Subscribe. This
is the rule that matters most.
Adding server data
If your widget needs something the console does not already receive, claim the packet
and subscribe to the topic it raises:
this.Subscribe("vessel-damage", (m) => { this.Render(); });
this.Subscribe("ready", (m) => {
this.AcceptPacket("DAMAGE");
});
Subscribe to the topic before you claim the packet. If another widget already holds
it, your claim is answered on the spot by replaying the packet's topics, and a widget
that has not subscribed yet misses them. Claiming in a ready handler, as
here, is the easy way to get the order right: it runs once the constructor has finished.
You do not need to wait for the socket; a claim made before it opens is sent when it
connects. And claim through this.AcceptPacket so the claim is released when
the widget is — the global does not track owners for you. See
Packets And Topics.
Check what a packet actually raises before you rely on it; the mapping is in
ProcessCMD in hydra.js. Not every packet raises a topic:
DAMAGE-TEAMS, for instance, updates
thisvessel.DamageTeams and announces nothing.
Using a template
Widgets that render repeated rows load an HTML template rather than building strings
inline:
this.layout = options.layout || "/templates/damage-listing.htm";
GetContent(this.layout).then((data) => {
this.template = data;
this.Render();
});
Templates use [Name]‑style placeholders that the widget replaces.
Letting the layout be overridden through options.layout costs nothing and
lets a module restyle your widget without replacing it.
The server does not touch these files. A server token such as
[Server.IP] or [Screen.Title] written into a template or a
widget layout appears on screen exactly as written. Leave the element empty and fill it
in when you render, from the page's globals: swversion for the build
version, screenTitle for the screen's display name.
A layout can hold hz-widget elements of its own. After writing it into
your element, call this.Controller.Update() to mount them. If your element
may already hold widgets that another widget put there, call
this.Controller?.RemoveNested?.(this) before you write, and if the layout
arrives asynchronously, check that your widget was not disposed while it was on its way:
GetContent(this.layout).then((data) => {
if (this.Disposed)
return;
this.Controller?.RemoveNested?.(this);
SetHtml(this.parentID, data);
this.Controller?.Update();
});
Drawing in 3D or on a canvas
Ask for a canvas in the declaration:
<hz-widget id="radar" source="radar-3d" element="canvas" class="panel-full"></hz-widget>
three.js is available as a bare import, through the page's import map:
import * as THREE from "three";
A widget with its own render loop must stop it in Dispose(), and must not
create a second one on reset. A loop that queues itself should also check
this.Disposed at the top of every frame, which the base class sets before
anything else in Dispose():
Render() {
if (this.Disposed)
return;
// ... draw ...
this._raf = requestAnimationFrame(() => this.Render());
}
Showing what an object is
Do not write your own table of object types and icons. What an object looks like on
any display is answered in one place, /js/object-icons.js, which the radars
and the object lists all use:
import { ResolveIcon, IconStyleFor } from "/js/object-icons.js";
const icon = ResolveIcon(contact); // { mode, src, tint, color }
// A list row: the .item-glyph box draws the same picture the radar does.
html += "<div class='item-glyph' style=\"" + IconStyleFor(contact) + "\"></div>";
It reads two things about an object. BaseType is its kind (Vessel,
Model, Missile and so on) and Classification is what it is within that
kind. A station, a container or a drone arrives as a Vessel or an Object by kind, so a
switch on BaseType draws every one of them as a ship. The same goes for a
filter: to ask "is this a station?", ask Classification.
Cleaning up
Dispose() {
if (this._raf != null) {
cancelAnimationFrame(this._raf);
this._raf = null;
}
// ... release GPU objects, detach listeners ...
super.Dispose(); // <- required
}
The super.Dispose() call is what releases subscriptions and packets.
Forgetting it is a silent leak, and copying the base's body instead of calling it is the
same mistake with more typing.
Adding your own settings
A widget or a screen can add its own pane to the console's Settings panel without
touching the panel. Every time Settings opens it raises settings-render;
answer it by asking for a pane and filling the element you are handed. When the player
presses SET on any added pane, Settings raises settings-save, with the
pane's id as the value:
this.Subscribe("settings-render", (m) => {
const body = m.value.AddPane({ id: "my-widget", title: "My Widget", sub: "What these do" });
if (body != null)
body.innerHTML = "...settings-card markup, filled from what you stored...";
});
this.Subscribe("settings-save", (m) => {
// read your controls and store the values
});
What the controls are and where the values are kept is up to you. Panes are built
again each time the panel opens, so a widget that has been disposed simply stops
answering and its pane disappears. AddPane returns null when
the id is empty or already in use.
Page helpers
There is no jQuery on the page. hydra.js has small helpers instead,
such as SetHtml, SetText, SetValue,
GetValue, Show and Hide, each taking a selector.
A widget written for an older build that calls $(...) fails as soon as it
runs.
SetValue fires change. Filling a form
field by field therefore runs each field's onchange as you go. A handler
that reads every field and writes them all back to a record then runs while the later
fields are still blank, and blanks the record. Use oninput for handlers
like that (SetValue does not fire input), or give each field a
handler that reads only that field.
Checklist
- Does every subscription go through
this.Subscribe?
- Does every packet claim go through
this.AcceptPacket?
- If
Dispose() is overridden, does it call super.Dispose()?
- Is everything the constructor does safe to do twice? Reset re‑runs
initialization on a page that is already running. This is where most widget bugs are.
- Does a loop that queues itself with
requestAnimationFrame stop when
this.Disposed is set?
- Does it subscribe to a topic before it claims the packet that raises it?
- Is the element id unique on the page? Two declarations with one id mount two
widgets against one element.
- Does it survive
thisvessel being null? It will be, before assignment
and after a reset.
- Does it do work when nothing has changed? A widget re‑rendering on every
contact update in a busy mission will cost more than the entire 3D view.
Shipping it
Put the file at Html/js/widgets/<source>.js inside your module.
Module files layer over the base game's by path, so a module can add new widgets and
also replace existing ones. See Modules. api/widget-sources lists
every widget the server can serve and which module's copy is the one in use.
To make it appear on a console without editing that console, declare it from a
component, a vessel class or a mission, or attach
it to every screen that carries a given tag (see Widgets). That is the intended
route, and it is what makes a widget content rather than a code change.
Debugging
The widget controller logs each widget as it mounts, and warns about duplicate ids.
From the browser console, workbench.Widgets.Instances lists everything
mounted and workbench.Widgets.Find("...") gets you a live handle to poke
at. Import failures are reported as errors — if a widget simply never appears, that
is the first place to look.
If widgets are plainly on screen but workbench.Widgets.Instances is
empty and Find finds nothing, look for a script on the screen that imports
/js/hydra-module.js. That import starts a second, empty widget controller.
See Client Architecture.