fix: slotType serial + feaures

- fix slotType serialisation
- show slot images including class if no occupant
- filter/search device list
This commit is contained in:
Rachel Powers
2024-04-17 14:21:41 -07:00
parent 7e2ea652c3
commit b60cc44580
29 changed files with 326 additions and 81 deletions

View File

@@ -14,6 +14,7 @@ use grammar::{BatchMode, LogicType, ReagentMode, SlotLogicType};
use interpreter::{ICError, LineError};
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use strum_macros::{EnumIter, EnumString, AsRefStr};
use thiserror::Error;
use crate::interpreter::ICState;
@@ -348,8 +349,22 @@ impl Connection {
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(
Debug,
Default,
Clone,
Copy,
PartialEq,
Eq,
Hash,
strum_macros::Display,
EnumString,
EnumIter,
AsRefStr,
Serialize,
Deserialize,
)]
#[strum(serialize_all = "PascalCase")]
pub enum SlotType {
Helmet = 1,
Suit = 2,
@@ -376,7 +391,7 @@ pub enum SlotType {
Magazine,
Circuit = 24,
Bottle,
ProgramableChip,
ProgrammableChip,
Glasses,
CreditCard,
DirtCanister,
@@ -390,7 +405,6 @@ pub enum SlotType {
Flare,
Blocked,
#[default]
#[serde(other)]
None = 0,
}
@@ -423,8 +437,7 @@ impl Network {
}
pub fn contains_all(&self, ids: &[u32]) -> bool {
ids.iter()
.all(|id| self.contains(id))
ids.iter().all(|id| self.contains(id))
}
pub fn contains_data(&self, id: &u32) -> bool {
@@ -588,7 +601,7 @@ impl Device {
),
]);
device.slots.push(Slot::with_occupant(
SlotType::ProgramableChip,
SlotType::ProgrammableChip,
// -744098481 = ItemIntegratedCircuit10
SlotOccupant::new(ic, -744098481),
));
@@ -710,7 +723,7 @@ impl Device {
.slots
.get(index as usize)
.ok_or(ICError::SlotIndexOutOfRange(index))?;
if slot.typ == SlotType::ProgramableChip
if slot.typ == SlotType::ProgrammableChip
&& slot.occupant.is_some()
&& self.ic.is_some()
&& typ == SlotLogicType::LineNumber
@@ -743,7 +756,7 @@ impl Device {
.get(index as usize)
.ok_or(ICError::SlotIndexOutOfRange(index))?;
let mut fields = slot.get_fields();
if slot.typ == SlotType::ProgramableChip && slot.occupant.is_some() && self.ic.is_some() {
if slot.typ == SlotType::ProgrammableChip && slot.occupant.is_some() && self.ic.is_some() {
// try borrow to get ip, we should only fail if the ic is in us aka is is *our* ic
if let Ok(ic) = vm
.ics
@@ -784,7 +797,7 @@ impl Device {
.slots
.get_mut(index as usize)
.ok_or(ICError::SlotIndexOutOfRange(index))?;
if slot.typ == SlotType::ProgramableChip
if slot.typ == SlotType::ProgrammableChip
&& slot.occupant.is_some()
&& self.ic.is_some()
&& typ == SlotLogicType::LineNumber
@@ -1114,7 +1127,7 @@ impl VM {
let ic = slots
.iter()
.find_map(|slot| {
if slot.typ == SlotType::ProgramableChip && slot.occupant.is_some() {
if slot.typ == SlotType::ProgrammableChip && slot.occupant.is_some() {
Some(slot.occupant.clone()).flatten()
} else {
None

View File

@@ -2,7 +2,10 @@
mod utils;
mod types;
use ic10emu::{grammar::{LogicType, SlotLogicType}, DeviceTemplate};
use ic10emu::{
grammar::{LogicType, SlotLogicType},
DeviceTemplate,
};
use serde::{Deserialize, Serialize};
use types::{Registers, Stack};
@@ -61,12 +64,20 @@ impl DeviceRef {
#[wasm_bindgen(getter, js_name = "prefabName")]
pub fn prefab_name(&self) -> Option<String> {
self.device.borrow().prefab.as_ref().map(|prefab| prefab.name.clone())
self.device
.borrow()
.prefab
.as_ref()
.map(|prefab| prefab.name.clone())
}
#[wasm_bindgen(getter, js_name = "prefabHash")]
pub fn prefab_hash(&self) -> Option<i32> {
self.device.borrow().prefab.as_ref().map(|prefab| prefab.hash)
self.device
.borrow()
.prefab
.as_ref()
.map(|prefab| prefab.hash)
}
#[wasm_bindgen(getter, skip_typescript)]
@@ -354,6 +365,10 @@ impl VM {
#[wasm_bindgen(js_name = "addDeviceFromTemplate", skip_typescript)]
pub fn add_device_from_template(&self, template: JsValue) -> Result<u32, JsError> {
let template: DeviceTemplate = serde_wasm_bindgen::from_value(template)?;
web_sys::console::log_2(
&"(wasm) adding device".into(),
&serde_wasm_bindgen::to_value(&template).unwrap(),
);
Ok(self.vm.borrow_mut().add_device_from_template(template)?)
}

View File

@@ -15,7 +15,7 @@
},
{
"cell_type": "code",
"execution_count": 1,
"execution_count": 4,
"metadata": {},
"outputs": [],
"source": [
@@ -38,7 +38,7 @@
},
{
"cell_type": "code",
"execution_count": 2,
"execution_count": 5,
"metadata": {},
"outputs": [],
"source": [
@@ -58,7 +58,7 @@
},
{
"cell_type": "code",
"execution_count": 3,
"execution_count": 6,
"metadata": {},
"outputs": [],
"source": [
@@ -80,7 +80,7 @@
},
{
"cell_type": "code",
"execution_count": 4,
"execution_count": 10,
"metadata": {},
"outputs": [],
"source": [
@@ -178,8 +178,7 @@
" candidates.append((name, entry[\"name\"].removeprefix(\"Item\"), entry[\"name\"]))\n",
" if entry[\"name\"].removeprefix(\"Structure\") in name:\n",
" candidates.append((name, entry[\"name\"].removeprefix(\"Structure\"), entry[\"name\"]))\n",
" image_candidates[entry[\"name\"]] = filter_candidates(candidates)\n",
"\n"
" image_candidates[entry[\"name\"]] = filter_candidates(candidates)"
]
},
{
@@ -191,7 +190,7 @@
},
{
"cell_type": "code",
"execution_count": 5,
"execution_count": 17,
"metadata": {},
"outputs": [],
"source": [
@@ -212,30 +211,30 @@
},
{
"cell_type": "code",
"execution_count": 6,
"execution_count": 18,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"ItemBiomass []\n",
"StructureBlocker []\n",
"CartridgePlantAnalyser []\n",
"StructureElevatorLevelIndustrial []\n",
"ItemPlantEndothermic_Creative []\n",
"Flag_ODA_10m []\n",
"Flag_ODA_4m []\n",
"Flag_ODA_6m []\n",
"Flag_ODA_8m []\n",
"ItemBiomass []\n",
"ItemHorticultureBelt []\n",
"ItemKitLiquidRegulator []\n",
"ItemKitPortablesConnector []\n",
"ItemMushroom ['ItemMushroom-resources.assets-3022.png', 'ItemMushroom-resources.assets-9304.png']\n",
"ItemPlantEndothermic_Creative []\n",
"ItemPlantThermogenic_Creative []\n",
"Landingpad_GasConnectorInwardPiece []\n",
"Landingpad_LiquidConnectorInwardPiece []\n",
"ItemMushroom ['ItemMushroom-resources.assets-3022.png', 'ItemMushroom-resources.assets-9304.png']\n",
"StructurePlinth []\n",
"ItemPlantThermogenic_Creative []\n"
"StructureBlocker []\n",
"StructureElevatorLevelIndustrial []\n",
"StructurePlinth []\n"
]
}
],
@@ -254,14 +253,95 @@
},
{
"cell_type": "code",
"execution_count": 11,
"execution_count": 21,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"1223 of 1223 | 100.00% \n",
"missing Egg\n",
"missing Appliance\n",
"missing Ingot\n",
"missing Torpedo\n",
"missing Magazine\n",
"missing SensorProcessingUnit\n",
"missing LiquidCanister\n",
"missing LiquidBottle\n",
"missing Wreckage\n",
"missing SoundCartridge\n",
"missing DrillHead\n",
"missing ScanningHead\n",
"missing Flare\n",
"missing Blocked\n"
]
}
],
"source": [
"slot_types = [\n",
" \"Helmet\",\n",
" \"Suit\",\n",
" \"Back\",\n",
" \"GasFilter\",\n",
" \"GasCanister\",\n",
" \"MotherBoard\",\n",
" \"Circuitboard\",\n",
" \"DataDisk\",\n",
" \"Organ\",\n",
" \"Ore\",\n",
" \"Plant\",\n",
" \"Uniform\",\n",
" \"Entity\",\n",
" \"Battery\",\n",
" \"Egg\",\n",
" \"Belt\",\n",
" \"Tool\",\n",
" \"Appliance\",\n",
" \"Ingot\",\n",
" \"Torpedo\",\n",
" \"Cartridge\",\n",
" \"AccessCard\",\n",
" \"Magazine\",\n",
" \"Circuit\",\n",
" \"Bottle\",\n",
" \"ProgrammableChip\",\n",
" \"Glasses\",\n",
" \"CreditCard\",\n",
" \"DirtCanister\",\n",
" \"SensorProcessingUnit\",\n",
" \"LiquidCanister\",\n",
" \"LiquidBottle\",\n",
" \"Wreckage\",\n",
" \"SoundCartridge\",\n",
" \"DrillHead\",\n",
" \"ScanningHead\",\n",
" \"Flare\",\n",
" \"Blocked\",\n",
"]\n",
"sloticons = []\n",
"for typ in slot_types:\n",
" try_name = f\"sloticon_{typ.lower()}\"\n",
" found = False\n",
" for name in names:\n",
" if name.startswith(try_name):\n",
" sloticons.append([f\"SlotIcon_{typ}\", name])\n",
" found = True\n",
" if not found:\n",
" print(f\"missing {typ}\")\n",
"\n",
"to_copy.extend(sloticons)"
]
},
{
"cell_type": "code",
"execution_count": 22,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"1266 of 1266 | 100.00% \n",
"Done\n"
]
}
@@ -284,13 +364,6 @@
"print(\"Done\")\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": []
}
],
"metadata": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 791 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -22,6 +22,9 @@ export class IC10Details extends SlDetails {
.details__summary-icon {
cursor: pointer;
}
.details__content {
padding-top: 0;
}
`,
];

View File

@@ -10,6 +10,7 @@ import {
SlotOccupantTemplate,
SlotLogicType,
ConnectionCableNetwork,
SlotType,
} from "ic10emu_wasm";
import { html, css, HTMLTemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
@@ -94,6 +95,13 @@ export class VMDeviceCard extends VMDeviceMixin(BaseElement) {
.device-name-hash::part(input) {
width: 7rem;
}
.slot-header.image {
width: 1.5rem;
height: 1.5rem;
border: var(--sl-panel-border-width) solid var(--sl-panel-border-color);
border-radius: var(--sl-border-radius-medium);
background-color: var(--sl-color-neutral-0);
}
sl-divider {
--spacing: 0.25rem;
}
@@ -119,12 +127,35 @@ export class VMDeviceCard extends VMDeviceMixin(BaseElement) {
padding: var(--sl-spacing-small) var(--sl-spacing-medium);
}
sl-tab-group::part(base) {
height: 16rem;
max-height: 20rem;
overflow-y: auto;
}
`,
];
private _deviceDB: DeviceDB;
get deviceDB(): DeviceDB {
return this._deviceDB;
}
@state()
set deviceDB(val: DeviceDB) {
this._deviceDB = val;
}
connectedCallback(): void {
super.connectedCallback();
window.VM!.addEventListener(
"vm-device-db-loaded",
this._handleDeviceDBLoad.bind(this),
);
}
_handleDeviceDBLoad(e: CustomEvent) {
this.deviceDB = e.detail;
}
onImageErr(e: Event) {
this.image_err = true;
console.log("Image load error", e);
@@ -145,7 +176,8 @@ export class VMDeviceCard extends VMDeviceMixin(BaseElement) {
}, this);
return html`
<sl-tooltip content="${this.prefabName}">
<img class="image" src="img/stationpedia/${this.prefabName}.png" @onerr=${this.onImageErr} />
<img class="image" src="img/stationpedia/${this.prefabName}.png"
onerror="this.src = '${VMDeviceCard.transparentImg}'" />
</sl-tooltip>
<div class="header-name">
<sl-input id="vmDeviceCard${this.deviceID}Id" class="device-id" size="small" pill value=${this.deviceID}
@@ -173,7 +205,7 @@ export class VMDeviceCard extends VMDeviceMixin(BaseElement) {
const inputIdBase = `vmDeviceCard${this.deviceID}Field`;
return html`
${fields.map(([name, field], _index, _fields) => {
return html` <sl-input id="${inputIdBase}${name}" key="${name}" value="${field.value}"
return html` <sl-input id="${inputIdBase}${name}" key="${name}" value="${field.value}" size="small"
?disabled=${field.field_type==="Read" } @sl-change=${this._handleChangeField}>
<span slot="prefix">${name}</span>
<sl-copy-button slot="suffix" from="${inputIdBase}${name}.value"></sl-copy-button>
@@ -183,17 +215,53 @@ export class VMDeviceCard extends VMDeviceMixin(BaseElement) {
`;
}
lookupSlotOccupantImg(
occupant: SlotOccupant | undefined,
typ: SlotType,
): string {
if (typeof occupant !== "undefined") {
const hashLookup = (this.deviceDB ?? {}).names_by_hash ?? {};
const prefabName = hashLookup[occupant.prefab_hash] ?? "UnknownHash";
return `img/stationpedia/${prefabName}.png`;
} else {
return `img/stationpedia/SlotIcon_${typ}.png`;
}
}
_onSlotImageErr(e: Event) {
console.log("image_err", e);
}
static transparentImg =
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" as const;
renderSlot(slot: Slot, slotIndex: number): HTMLTemplateResult {
const _fields = this.device.getSlotFields(slotIndex);
const fields = Array.from(_fields.entries());
const inputIdBase = `vmDeviceCard${this.deviceID}Slot${slotIndex}Field`;
const slotImg = this.lookupSlotOccupantImg(slot.occupant, slot.typ);
return html`
<sl-card class="slot-card">
<img slot="header" class="slot-header image" src="${slotImg}"
onerror="this.src = '${VMDeviceCard.transparentImg}'" />
<span slot="header" class="slot-header">${slotIndex} : ${slot.typ}</span>
${
typeof slot.occupant !== "undefined"
? html`
<span slot="header" class="slot-header">
Occupant: ${slot.occupant.id} : ${slot.occupant.prefab_hash}
</span>
<span slot="header" class="slot-header">
Quantity: ${slot.occupant.quantity}/
${slot.occupant.max_quantity}
</span>
`
: ""
}
<div class="slot-fields">
${fields.map(
([name, field], _index, _fields) => html`
<sl-input id="${inputIdBase}${name}" slotIndex=${slotIndex} key="${name}" value="${field.value}"
<sl-input id="${inputIdBase}${name}" slotIndex=${slotIndex} key="${name}" value="${field.value}" size="small"
?disabled=${field.field_type==="Read" } @sl-change=${this._handleChangeSlotField}>
<span slot="prefix">${name}</span>
<sl-copy-button slot="suffix" from="${inputIdBase}${name}.value"></sl-copy-button>
@@ -221,42 +289,49 @@ export class VMDeviceCard extends VMDeviceMixin(BaseElement) {
renderNetworks(): HTMLTemplateResult {
const vmNetworks = window.VM!.networks;
return html`
<div class="networks">
${this.connections.map((connection, index, _conns) => {
const conn =
typeof connection === "object" ? connection.CableNetwork : null;
return html`
<sl-select hoist placement="top" clearable key=${index} value=${conn?.net} ?disabled=${conn===null}
@sl-change=${this._handleChangeConnection}>
<span slot="prefix">Connection:${index} </span>
${vmNetworks.map(
(net) =>
html`<sl-option value=${net}>Network ${net}</sl-option>`,
)}
<span slot="prefix"> ${conn?.typ} </span>
</sl-select>
`;
})}
</div>
`;
< div class="networks" >
${
this.connections.map((connection, index, _conns) => {
const conn =
typeof connection === "object" ? connection.CableNetwork : null;
return html`
<sl-select hoist placement="top" clearable key=${index} value=${conn?.net} ?disabled=${conn===null}
@sl-change=${this._handleChangeConnection}>
<span slot="prefix">Connection:${index} </span>
${vmNetworks.map(
(net) =>
html`<sl-option value=${net}>Network ${net}</sl-option>`,
)}
<span slot="prefix"> ${conn?.typ} </span>
</sl-select>
`;
})
}
</div>
`;
}
renderPins(): HTMLTemplateResult {
const pins = this.pins;
const visibleDevices = window.VM!.visibleDevices(this.deviceID);
return html`
<div class="pins">
${pins?.map(
(pin, index) =>
html`<sl-select hoist placement="top" clearable key=${index} value=${pin} @sl-change=${this._handleChangePin}>
<span slot="prefix">d${index}</span>
${visibleDevices.map(
(device, _index) =>
html`<sl-option value=${device.id}>
Device ${device.id} : ${device.name ?? device.prefabName}
</sl-option>`,
)}
</sl-select>`,
)}
< div class="pins" >
${
pins?.map(
(pin, index) =>
html`
<sl-select hoist placement="top" clearable key=${index} value=${pin} @sl-change=${this._handleChangePin}>
<span slot="prefix">d${index}</span>
${visibleDevices.map(
(device, _index) =>
html`
<sl-option value=${device.id}>
Device ${device.id} : ${device.name ?? device.prefabName}
</sl-option>
`,
)}
</sl-select>`,
)
}
</div>
`;
}
@@ -383,9 +458,9 @@ export class VMDeviceList extends BaseElement {
}
protected render(): HTMLTemplateResult {
const deviceCards: HTMLTemplateResult[] = this.devices.map(
const deviceCards: HTMLTemplateResult[] = this.filteredDeviceIds.map(
(id, _index, _ids) =>
html`<vm-device-card .deviceID=${id} class="device-list-card"></vm-device-card>`,
html`< vm - device - card.deviceID=${ id } class="device-list-card" > </vm-device-card>`,
);
const result = html`
<div class="header">
@@ -393,6 +468,9 @@ export class VMDeviceList extends BaseElement {
Devices:
<sl-badge variant="neutral" pill>${this.devices.length}</sl-badge>
</span>
<sl-input class="device-filter-input" placeholder="Filter Devices" clearable @sl-input=${this._handleFilterInput}>
<sl-icon slot="suffix" name="search"></sl-icon>"
</sl-input>
<vm-add-device-button class="ms-auto"></vm-add-device-button>
</div>
<div class="device-list">${deviceCards}</div>
@@ -400,6 +478,70 @@ export class VMDeviceList extends BaseElement {
return result;
}
get filteredDeviceIds() {
if (typeof this._filteredDeviceIds !== "undefined") {
return this._filteredDeviceIds;
} else {
return this.devices;
}
}
private _filteredDeviceIds: number[] | undefined;
private _filter: string = "";
@query(".device-filter-input") accessor filterInput: SlInput;
get filter() {
return this._filter;
}
@state()
set filter(val: string) {
this._filter = val;
this.performSearch();
}
private filterTimeout: number | undefined;
_handleFilterInput(_e: CustomEvent) {
if (this.filterTimeout) {
clearTimeout(this.filterTimeout);
}
const that = this;
this.filterTimeout = setTimeout(() => {
that.filter = that.filterInput.value;
that.filterTimeout = undefined;
}, 200);
}
performSearch() {
if (this._filter) {
const datapoints: [string, number][] = [];
for (const device_id of this.devices) {
const device = window.VM.devices.get(device_id);
if (device) {
if (typeof device.name !== "undefined") {
datapoints.push([device.name, device.id]);
}
if (typeof device.prefabName !== "undefined") {
datapoints.push([device.prefabName, device.id]);
}
}
}
const haystack: string[] = datapoints.map((data) => data[0]);
const uf = new uFuzzy({});
const [_idxs, info, order] = uf.search(haystack, this._filter, 0, 1e3);
const filtered = order?.map((infoIdx) => datapoints[info.idx[infoIdx]]);
const deviceIds: number[] =
filtered
?.map((data) => data[1])
?.filter((val, index, arr) => arr.indexOf(val) === index) ?? [];
this._filteredDeviceIds = deviceIds;
} else {
this._filteredDeviceIds = undefined;
}
}
}
@customElement("vm-add-device-button")
@@ -465,7 +607,7 @@ export class VMAddDeviceButton extends BaseElement {
this.performSearch();
}
_filter: string = "";
private _filter: string = "";
get filter() {
return this._filter;
@@ -482,7 +624,7 @@ export class VMAddDeviceButton extends BaseElement {
private filterTimeout: number | undefined;
performSearch() {
if (this.filter) {
if (this._filter) {
const uf = new uFuzzy({});
const [_idxs, info, order] = uf.search(
this._haystack,
@@ -556,7 +698,6 @@ export class VMAddDeviceButton extends BaseElement {
}
_handleSearchInput(e: CustomEvent) {
console.log("search-input", e);
if (this.filterTimeout) {
clearTimeout(this.filterTimeout);
}
@@ -687,12 +828,11 @@ export class VmDeviceTemplate extends BaseElement {
}
connectedCallback(): void {
const root = super.connectedCallback();
super.connectedCallback();
window.VM!.addEventListener(
"vm-device-db-loaded",
this._handleDeviceDBLoad.bind(this),
);
return root;
}
_handleDeviceDBLoad(e: CustomEvent) {
@@ -784,7 +924,8 @@ export class VmDeviceTemplate extends BaseElement {
<sl-card class="template-card">
<div class="header" slot="header">
<sl-tooltip content="${device?.name}">
<img class="image" src="img/stationpedia/${device?.name}.png" @onerr=${this.onImageErr} />
<img class="image" src="img/stationpedia/${device?.name}.png"
onerror="this.src = '${VMDeviceCard.transparentImg}'" />
</sl-tooltip>
<div class="vstack">
<span class="prefab-name">${device?.name}</span>