30 changed files with 1501 additions and 52 deletions
@ -0,0 +1,135 @@ |
|||||
|
#### Container function |
||||
|
|
||||
|
<div class="divider"></div> |
||||
|
<br/> |
||||
|
|
||||
|
*function (ctx): void* |
||||
|
|
||||
|
A JavaScript function used to expose data and handlers to the Angular template by assigning them to <code>this</code>. Reference them in the template without the <code>this</code> prefix. |
||||
|
|
||||
|
**Parameters:** |
||||
|
|
||||
|
<ul> |
||||
|
<li><b>ctx:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/models/widget-component.models.ts#L107" target="_blank">WidgetContext</a></code> - A reference to <a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/models/widget-component.models.ts#L107" target="_blank">WidgetContext</a> that has all necessary API |
||||
|
and data used by widget instance.<br/> |
||||
|
Provides access to send RPC, switch dashboard states, create data subscriptions, read settings and access platform services. |
||||
|
</li> |
||||
|
</ul> |
||||
|
|
||||
|
**Returns:** |
||||
|
|
||||
|
This function does not return any value. Expose values and handlers to the template by assigning them to <code>this</code>. |
||||
|
|
||||
|
<div class="divider"></div> |
||||
|
|
||||
|
##### Notes |
||||
|
|
||||
|
<ul> |
||||
|
<li>There is no <code>container</code> parameter in Angular mode. Access the root element via <code>ctx.$container[0]</code> or <code>event.currentTarget.closest(...)</code> from a handler.</li> |
||||
|
<li>Define helper functions as arrow functions to preserve <code>this</code>.</li> |
||||
|
<li>Call <code>ctx.detectChanges()</code> after changing template-bound values so the template re-renders.</li> |
||||
|
<li>This widget does <b>not</b> bind widget datasources automatically. There is no <code>ctx.data</code> populated from the widget configuration. To read live data create an explicit subscription via <code>ctx.subscriptionApi.createSubscription(...)</code> and release it from <code>ctx.registerDestroyCallback(...)</code>.</li> |
||||
|
</ul> |
||||
|
|
||||
|
<div class="divider"></div> |
||||
|
|
||||
|
##### Examples |
||||
|
|
||||
|
###### Resizable split master–detail with embedded dashboard state |
||||
|
|
||||
|
<br> |
||||
|
|
||||
|
<div style="padding-left: 64px;" |
||||
|
tb-help-popup="widget/lib/html_container/examples/angular_split_html" |
||||
|
tb-help-popup-placement="top" |
||||
|
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}" |
||||
|
trigger-style="font-size: 16px;" |
||||
|
trigger-text="HTML template"> |
||||
|
</div> |
||||
|
|
||||
|
<br> |
||||
|
|
||||
|
<div style="padding-left: 64px;" |
||||
|
tb-help-popup="widget/lib/html_container/examples/angular_split_css" |
||||
|
tb-help-popup-placement="top" |
||||
|
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}" |
||||
|
trigger-style="font-size: 16px;" |
||||
|
trigger-text="CSS"> |
||||
|
</div> |
||||
|
|
||||
|
<br> |
||||
|
|
||||
|
<div style="padding-left: 64px;" |
||||
|
tb-help-popup="widget/lib/html_container/examples/angular_split_js" |
||||
|
tb-help-popup-placement="top" |
||||
|
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}" |
||||
|
trigger-style="font-size: 16px;" |
||||
|
trigger-text="JavaScript function"> |
||||
|
</div> |
||||
|
|
||||
|
###### Slide-over device detail with two-way RPC |
||||
|
|
||||
|
<br> |
||||
|
|
||||
|
<div style="padding-left: 64px;" |
||||
|
tb-help-popup="widget/lib/html_container/examples/angular_drawer_html" |
||||
|
tb-help-popup-placement="top" |
||||
|
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}" |
||||
|
trigger-style="font-size: 16px;" |
||||
|
trigger-text="HTML template"> |
||||
|
</div> |
||||
|
|
||||
|
<br> |
||||
|
|
||||
|
<div style="padding-left: 64px;" |
||||
|
tb-help-popup="widget/lib/html_container/examples/angular_drawer_css" |
||||
|
tb-help-popup-placement="top" |
||||
|
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}" |
||||
|
trigger-style="font-size: 16px;" |
||||
|
trigger-text="CSS"> |
||||
|
</div> |
||||
|
|
||||
|
<br> |
||||
|
|
||||
|
<div style="padding-left: 64px;" |
||||
|
tb-help-popup="widget/lib/html_container/examples/angular_drawer_js" |
||||
|
tb-help-popup-placement="top" |
||||
|
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}" |
||||
|
trigger-style="font-size: 16px;" |
||||
|
trigger-text="JavaScript function"> |
||||
|
</div> |
||||
|
|
||||
|
###### Composite dashboard with tab navigation |
||||
|
|
||||
|
<br> |
||||
|
|
||||
|
<div style="padding-left: 64px;" |
||||
|
tb-help-popup="widget/lib/html_container/examples/angular_tabs_html" |
||||
|
tb-help-popup-placement="top" |
||||
|
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}" |
||||
|
trigger-style="font-size: 16px;" |
||||
|
trigger-text="HTML template"> |
||||
|
</div> |
||||
|
|
||||
|
<br> |
||||
|
|
||||
|
<div style="padding-left: 64px;" |
||||
|
tb-help-popup="widget/lib/html_container/examples/angular_tabs_css" |
||||
|
tb-help-popup-placement="top" |
||||
|
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}" |
||||
|
trigger-style="font-size: 16px;" |
||||
|
trigger-text="CSS"> |
||||
|
</div> |
||||
|
|
||||
|
<br> |
||||
|
|
||||
|
<div style="padding-left: 64px;" |
||||
|
tb-help-popup="widget/lib/html_container/examples/angular_tabs_js" |
||||
|
tb-help-popup-placement="top" |
||||
|
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}" |
||||
|
trigger-style="font-size: 16px;" |
||||
|
trigger-text="JavaScript function"> |
||||
|
</div> |
||||
|
|
||||
|
<br> |
||||
|
<br> |
||||
@ -0,0 +1,124 @@ |
|||||
|
#### Container function |
||||
|
|
||||
|
<div class="divider"></div> |
||||
|
<br/> |
||||
|
|
||||
|
*function (ctx, container): void* |
||||
|
|
||||
|
A JavaScript function used to initialize libraries, build or update the DOM inside the widget content element, wire up event handlers and drive the widget through the widget context. |
||||
|
|
||||
|
**Parameters:** |
||||
|
|
||||
|
<ul> |
||||
|
<li><b>ctx:</b> <code><a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/models/widget-component.models.ts#L107" target="_blank">WidgetContext</a></code> - A reference to <a href="https://github.com/thingsboard/thingsboard/blob/5bb6403407aa4898084832d6698aa9ea6d484889/ui-ngx/src/app/modules/home/models/widget-component.models.ts#L107" target="_blank">WidgetContext</a> that has all necessary API |
||||
|
and data used by widget instance.<br/> |
||||
|
Provides access to send RPC, switch dashboard states, create data subscriptions, read settings and access platform services. |
||||
|
</li> |
||||
|
<li><b>container:</b> <code>HTMLElement</code> - The widget's content element, a native DOM node (<code><div class="tb-absolute-fill"></code>) wrapping the rendered template.<br/> |
||||
|
Query it with <code>container.querySelector(...)</code>. |
||||
|
</li> |
||||
|
</ul> |
||||
|
|
||||
|
**Returns:** |
||||
|
|
||||
|
This function does not return any value. |
||||
|
|
||||
|
<div class="divider"></div> |
||||
|
|
||||
|
##### Notes |
||||
|
|
||||
|
<ul> |
||||
|
<li>This widget does <b>not</b> bind widget datasources automatically. There is no <code>ctx.data</code> populated from the widget configuration. To read live data create an explicit subscription via <code>ctx.subscriptionApi.createSubscription(...)</code>.</li> |
||||
|
<li>Release every subscription, timer and global event listener you create from <code>ctx.registerDestroyCallback(...)</code> so the widget cleans up correctly.</li> |
||||
|
</ul> |
||||
|
|
||||
|
<div class="divider"></div> |
||||
|
|
||||
|
##### Examples |
||||
|
|
||||
|
###### Kanban board with a CDN drag-and-drop library |
||||
|
|
||||
|
<br> |
||||
|
|
||||
|
<div style="padding-left: 64px;" |
||||
|
tb-help-popup="widget/lib/html_container/examples/plain_kanban_resources" |
||||
|
tb-help-popup-placement="top" |
||||
|
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}" |
||||
|
trigger-style="font-size: 16px;" |
||||
|
trigger-text="Resources"> |
||||
|
</div> |
||||
|
|
||||
|
<br> |
||||
|
|
||||
|
<div style="padding-left: 64px;" |
||||
|
tb-help-popup="widget/lib/html_container/examples/plain_kanban_html" |
||||
|
tb-help-popup-placement="top" |
||||
|
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}" |
||||
|
trigger-style="font-size: 16px;" |
||||
|
trigger-text="HTML code"> |
||||
|
</div> |
||||
|
|
||||
|
<br> |
||||
|
|
||||
|
<div style="padding-left: 64px;" |
||||
|
tb-help-popup="widget/lib/html_container/examples/plain_kanban_css" |
||||
|
tb-help-popup-placement="top" |
||||
|
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}" |
||||
|
trigger-style="font-size: 16px;" |
||||
|
trigger-text="CSS"> |
||||
|
</div> |
||||
|
|
||||
|
<br> |
||||
|
|
||||
|
<div style="padding-left: 64px;" |
||||
|
tb-help-popup="widget/lib/html_container/examples/plain_kanban_js" |
||||
|
tb-help-popup-placement="top" |
||||
|
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}" |
||||
|
trigger-style="font-size: 16px;" |
||||
|
trigger-text="JavaScript function"> |
||||
|
</div> |
||||
|
|
||||
|
###### Visitor analytics with a custom range and working hours |
||||
|
|
||||
|
<br> |
||||
|
|
||||
|
<div style="padding-left: 64px;" |
||||
|
tb-help-popup="widget/lib/html_container/examples/plain_analytics_resources" |
||||
|
tb-help-popup-placement="top" |
||||
|
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}" |
||||
|
trigger-style="font-size: 16px;" |
||||
|
trigger-text="Resources"> |
||||
|
</div> |
||||
|
|
||||
|
<br> |
||||
|
|
||||
|
<div style="padding-left: 64px;" |
||||
|
tb-help-popup="widget/lib/html_container/examples/plain_analytics_html" |
||||
|
tb-help-popup-placement="top" |
||||
|
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}" |
||||
|
trigger-style="font-size: 16px;" |
||||
|
trigger-text="HTML code"> |
||||
|
</div> |
||||
|
|
||||
|
<br> |
||||
|
|
||||
|
<div style="padding-left: 64px;" |
||||
|
tb-help-popup="widget/lib/html_container/examples/plain_analytics_css" |
||||
|
tb-help-popup-placement="top" |
||||
|
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}" |
||||
|
trigger-style="font-size: 16px;" |
||||
|
trigger-text="CSS"> |
||||
|
</div> |
||||
|
|
||||
|
<br> |
||||
|
|
||||
|
<div style="padding-left: 64px;" |
||||
|
tb-help-popup="widget/lib/html_container/examples/plain_analytics_js" |
||||
|
tb-help-popup-placement="top" |
||||
|
[tb-help-popup-style]="{maxHeight: '50vh', maxWidth: '50vw'}" |
||||
|
trigger-style="font-size: 16px;" |
||||
|
trigger-text="JavaScript function"> |
||||
|
</div> |
||||
|
|
||||
|
<br> |
||||
|
<br> |
||||
@ -0,0 +1,201 @@ |
|||||
|
#### CSS of slide-over device detail |
||||
|
|
||||
|
```css |
||||
|
{:code-style="max-height: 400px;"} |
||||
|
.dd { |
||||
|
height: 100%; |
||||
|
font-family: 'Inter', 'Roboto', system-ui, sans-serif; |
||||
|
color: #0f172a; |
||||
|
} |
||||
|
|
||||
|
.dd__main { |
||||
|
padding: 20px; |
||||
|
box-sizing: border-box; |
||||
|
} |
||||
|
|
||||
|
.dd__title { |
||||
|
margin: 0 0 16px; |
||||
|
font-family: 'Roboto', sans-serif; |
||||
|
font-size: 20px; |
||||
|
font-weight: 500; |
||||
|
} |
||||
|
|
||||
|
.dd__card { |
||||
|
background: #fff; |
||||
|
border-radius: 8px; |
||||
|
overflow: hidden; |
||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.12); |
||||
|
} |
||||
|
|
||||
|
.dd__table { |
||||
|
width: 100%; |
||||
|
} |
||||
|
|
||||
|
.dd__table .mat-column-name { |
||||
|
flex: 1; |
||||
|
} |
||||
|
|
||||
|
.dd__table .mat-column-people { |
||||
|
flex: 0 0 120px; |
||||
|
justify-content: flex-end; |
||||
|
} |
||||
|
|
||||
|
.dd__table .mat-mdc-row { |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
|
||||
|
.dd__table .mat-mdc-row:hover { |
||||
|
background: rgba(0, 0, 0, 0.04); |
||||
|
} |
||||
|
|
||||
|
.dd__table .mat-mdc-row.is-active { |
||||
|
background: rgba(47, 107, 255, 0.08); |
||||
|
} |
||||
|
|
||||
|
.dd__drawer { |
||||
|
width: 70%; |
||||
|
box-sizing: border-box; |
||||
|
} |
||||
|
|
||||
|
.dd__panel { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 16px; |
||||
|
padding: 24px; |
||||
|
} |
||||
|
|
||||
|
.dd__panel-bar { |
||||
|
display: flex; |
||||
|
align-items: flex-start; |
||||
|
justify-content: space-between; |
||||
|
} |
||||
|
|
||||
|
.dd__panel-eyebrow { |
||||
|
font-size: 11px; |
||||
|
font-weight: 700; |
||||
|
text-transform: uppercase; |
||||
|
letter-spacing: .08em; |
||||
|
color: #94a3b8; |
||||
|
} |
||||
|
|
||||
|
.dd__panel-title { |
||||
|
margin: 2px 0 0; |
||||
|
font-size: 22px; |
||||
|
font-weight: 800; |
||||
|
} |
||||
|
|
||||
|
.dd__close { |
||||
|
border: 0; |
||||
|
background: #f1f5f9; |
||||
|
border-radius: 10px; |
||||
|
width: 36px; |
||||
|
height: 36px; |
||||
|
font-size: 16px; |
||||
|
cursor: pointer; |
||||
|
color: #475569; |
||||
|
} |
||||
|
|
||||
|
.dd__close:hover { |
||||
|
background: #e2e8f0; |
||||
|
} |
||||
|
|
||||
|
.dd__cards { |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(3, 1fr); |
||||
|
gap: 12px; |
||||
|
} |
||||
|
|
||||
|
.card { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 4px; |
||||
|
padding: 14px 16px; |
||||
|
border-radius: 14px; |
||||
|
background: linear-gradient(180deg, #f8fafc, #eef2f7); |
||||
|
border: 1px solid #e6ebf2; |
||||
|
} |
||||
|
|
||||
|
.card__label { |
||||
|
font-size: 10px; |
||||
|
font-weight: 700; |
||||
|
text-transform: uppercase; |
||||
|
letter-spacing: .08em; |
||||
|
color: #94a3b8; |
||||
|
} |
||||
|
|
||||
|
.card__value { |
||||
|
font-size: 18px; |
||||
|
font-weight: 800; |
||||
|
} |
||||
|
|
||||
|
.dd__actions { |
||||
|
display: flex; |
||||
|
gap: 10px; |
||||
|
} |
||||
|
|
||||
|
.dd__action { |
||||
|
display: inline-flex; |
||||
|
flex-direction: column; |
||||
|
gap: 6px; |
||||
|
} |
||||
|
|
||||
|
.dd__btn { |
||||
|
padding: 9px 16px; |
||||
|
border: 1px solid #d8dee9; |
||||
|
border-radius: 10px; |
||||
|
background: #fff; |
||||
|
color: #0f172a; |
||||
|
font-size: 13px; |
||||
|
font-weight: 600; |
||||
|
cursor: pointer; |
||||
|
transition: background 0.15s ease; |
||||
|
} |
||||
|
|
||||
|
.dd__btn:hover { |
||||
|
background: #f8fafc; |
||||
|
} |
||||
|
|
||||
|
.dd__btn:disabled { |
||||
|
opacity: 0.6; |
||||
|
cursor: default; |
||||
|
} |
||||
|
|
||||
|
.dd__btn--warn { |
||||
|
color: #b91c1c; |
||||
|
border-color: #fecaca; |
||||
|
} |
||||
|
|
||||
|
.dd__btn--warn:hover { |
||||
|
background: #fef2f2; |
||||
|
} |
||||
|
|
||||
|
.dd__section { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 8px; |
||||
|
} |
||||
|
|
||||
|
.dd__section-title { |
||||
|
margin: 0; |
||||
|
font-size: 13px; |
||||
|
font-weight: 700; |
||||
|
color: #334155; |
||||
|
} |
||||
|
|
||||
|
.dd__state { |
||||
|
height: 280px; |
||||
|
border: 1px solid #eef1f6; |
||||
|
border-radius: 14px; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.dd__state tb-dashboard-state { |
||||
|
display: block; |
||||
|
width: 100%; |
||||
|
height: 100%; |
||||
|
} |
||||
|
{:copy-code} |
||||
|
``` |
||||
|
|
||||
|
<br> |
||||
|
<br> |
||||
@ -0,0 +1,66 @@ |
|||||
|
#### Angular template of slide-over device detail |
||||
|
|
||||
|
```html |
||||
|
{:code-style="max-height: 400px;"} |
||||
|
<mat-drawer-container class="dd"> |
||||
|
<mat-drawer-content class="dd__main"> |
||||
|
<h3 class="dd__title">Devices</h3> |
||||
|
<div class="dd__card"> |
||||
|
<mat-table [dataSource]="devices" class="dd__table"> |
||||
|
<ng-container matColumnDef="name"> |
||||
|
<mat-header-cell *matHeaderCellDef>Device</mat-header-cell> |
||||
|
<mat-cell *matCellDef="let d">{{ d.name }}</mat-cell> |
||||
|
</ng-container> |
||||
|
<ng-container matColumnDef="people"> |
||||
|
<mat-header-cell *matHeaderCellDef>People</mat-header-cell> |
||||
|
<mat-cell *matCellDef="let d">{{ d.lastValue }}</mat-cell> |
||||
|
</ng-container> |
||||
|
<mat-header-row *matHeaderRowDef="columns"></mat-header-row> |
||||
|
<mat-row *matRowDef="let d; columns: columns" [class.is-active]="d.entityId === selectedId" (click)="openDevice(d)"></mat-row> |
||||
|
</mat-table> |
||||
|
</div> |
||||
|
</mat-drawer-content> |
||||
|
|
||||
|
<mat-drawer class="dd__drawer" mode="over" position="end" [opened]="drawerOpen" (openedChange)="drawerOpen = $event"> |
||||
|
<div class="dd__panel" *ngIf="selected"> |
||||
|
<header class="dd__panel-bar"> |
||||
|
<div> |
||||
|
<div class="dd__panel-eyebrow">Device</div> |
||||
|
<h3 class="dd__panel-title">{{ selected.name }}</h3> |
||||
|
</div> |
||||
|
<button class="dd__close" (click)="drawerOpen = false">✕</button> |
||||
|
</header> |
||||
|
|
||||
|
<div class="dd__cards"> |
||||
|
<div class="card"><span class="card__label">People</span><span class="card__value">{{ selected.lastValue }}</span></div> |
||||
|
<div class="card"><span class="card__label">Entity type</span><span class="card__value">{{ selected.entityType }}</span></div> |
||||
|
<div class="card"><span class="card__label">Last update</span><span class="card__value">{{ selected.lastSeen }}</span></div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="dd__actions"> |
||||
|
<div class="dd__action"> |
||||
|
<button class="dd__btn" [disabled]="rebooting" (click)="reboot()">Reboot</button> |
||||
|
<mat-progress-bar *ngIf="rebooting" mode="indeterminate"></mat-progress-bar> |
||||
|
</div> |
||||
|
<div class="dd__action"> |
||||
|
<button class="dd__btn dd__btn--warn" [disabled]="resetting" (click)="resetCounter()">Reset counter</button> |
||||
|
<mat-progress-bar *ngIf="resetting" mode="indeterminate"></mat-progress-bar> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<section class="dd__section"> |
||||
|
<h4 class="dd__section-title">Alarms</h4> |
||||
|
<div class="dd__state"><tb-dashboard-state [ctx]="ctx" stateId="deviceAlarms" [syncParentStateParams]="true"></tb-dashboard-state></div> |
||||
|
</section> |
||||
|
<section class="dd__section"> |
||||
|
<h4 class="dd__section-title">Charts</h4> |
||||
|
<div class="dd__state"><tb-dashboard-state [ctx]="ctx" stateId="deviceCharts" [syncParentStateParams]="true"></tb-dashboard-state></div> |
||||
|
</section> |
||||
|
</div> |
||||
|
</mat-drawer> |
||||
|
</mat-drawer-container> |
||||
|
{:copy-code} |
||||
|
``` |
||||
|
|
||||
|
<br> |
||||
|
<br> |
||||
@ -0,0 +1,100 @@ |
|||||
|
#### JavaScript function of slide-over device detail |
||||
|
|
||||
|
```javascript |
||||
|
{:code-style="max-height: 400px;"} |
||||
|
this.ctx = ctx; |
||||
|
this.devices = []; |
||||
|
this.columns = ['name', 'people']; |
||||
|
this.selected = null; |
||||
|
this.selectedId = null; |
||||
|
this.drawerOpen = false; |
||||
|
|
||||
|
this.openDevice = (d) => { |
||||
|
this.selected = d; |
||||
|
this.selectedId = d.entityId; |
||||
|
this.drawerOpen = true; |
||||
|
ctx.stateController.updateState(null, { |
||||
|
entityId: { entityType: d.entityType, id: d.entityId }, |
||||
|
entityName: d.name, |
||||
|
}); |
||||
|
ctx.detectChanges(); |
||||
|
}; |
||||
|
|
||||
|
this.rebooting = false; |
||||
|
this.resetting = false; |
||||
|
|
||||
|
this.reboot = () => { |
||||
|
if (!this.selected || this.rebooting) return; |
||||
|
this.rebooting = true; |
||||
|
ctx.detectChanges(); |
||||
|
ctx.http.post('/api/rpc/twoway/' + this.selected.entityId, { method: 'reboot', params: {} }).subscribe({ |
||||
|
error: (e) => { |
||||
|
console.error(e); |
||||
|
this.rebooting = false; |
||||
|
ctx.detectChanges(); |
||||
|
}, |
||||
|
complete: () => { |
||||
|
this.rebooting = false; |
||||
|
ctx.detectChanges(); |
||||
|
}, |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
this.resetCounter = () => { |
||||
|
if (!this.selected || this.resetting) return; |
||||
|
this.resetting = true; |
||||
|
ctx.detectChanges(); |
||||
|
ctx.http.post('/api/rpc/twoway/' + this.selected.entityId, { method: 'resetCounter', params: {} }).subscribe({ |
||||
|
error: (e) => { |
||||
|
console.error(e); |
||||
|
this.resetting = false; |
||||
|
ctx.detectChanges(); |
||||
|
}, |
||||
|
complete: () => { |
||||
|
this.resetting = false; |
||||
|
ctx.detectChanges(); |
||||
|
}, |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
const subOpts = { |
||||
|
type: 'latest', |
||||
|
datasources: [ |
||||
|
{ |
||||
|
type: 'entity', |
||||
|
entityFilter: { type: 'deviceType', deviceTypes: ['peopleCount'], deviceNameFilter: '', resolveMultiple: true }, |
||||
|
dataKeys: [{ type: 'timeseries', name: 'peopleCount', settings: {} }], |
||||
|
}, |
||||
|
], |
||||
|
callbacks: { |
||||
|
onDataUpdated: (subscription) => { |
||||
|
this.devices = (subscription.data || []).map((entry) => { |
||||
|
const ds = entry.datasource; |
||||
|
const last = entry.data && entry.data.length ? entry.data[entry.data.length - 1] : null; |
||||
|
return { |
||||
|
entityId: ds.entityId, |
||||
|
entityType: ds.entityType, |
||||
|
name: ds.entityName, |
||||
|
lastValue: last ? last[1] : '—', |
||||
|
lastSeen: last ? new Date(last[0]).toLocaleString() : '—', |
||||
|
}; |
||||
|
}); |
||||
|
ctx.detectChanges(); |
||||
|
}, |
||||
|
onDataUpdateError: (subscription, e) => console.error(e), |
||||
|
}, |
||||
|
}; |
||||
|
|
||||
|
let subscriptionId = null; |
||||
|
ctx.subscriptionApi.createSubscription(subOpts, true).subscribe((subscription) => { |
||||
|
subscriptionId = subscription.id; |
||||
|
}); |
||||
|
|
||||
|
ctx.registerDestroyCallback(() => { |
||||
|
if (subscriptionId != null) ctx.subscriptionApi.removeSubscription(subscriptionId); |
||||
|
}); |
||||
|
{:copy-code} |
||||
|
``` |
||||
|
|
||||
|
<br> |
||||
|
<br> |
||||
@ -0,0 +1,142 @@ |
|||||
|
#### CSS of resizable split master–detail |
||||
|
|
||||
|
```css |
||||
|
{:code-style="max-height: 400px;"} |
||||
|
.split { |
||||
|
display: flex; |
||||
|
height: 100%; |
||||
|
box-sizing: border-box; |
||||
|
font-family: 'Inter', 'Roboto', system-ui, sans-serif; |
||||
|
color: #0f172a; |
||||
|
background: #fff; |
||||
|
border-radius: 16px; |
||||
|
box-shadow: 0 4px 24px rgba(15, 23, 42, 0.06); |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.split__list { |
||||
|
flex: 0 0 auto; |
||||
|
min-width: 160px; |
||||
|
overflow: auto; |
||||
|
border-right: 1px solid #eef1f6; |
||||
|
} |
||||
|
|
||||
|
.split__title { |
||||
|
margin: 0; |
||||
|
padding: 16px; |
||||
|
font-family: 'Roboto', sans-serif; |
||||
|
font-size: 20px; |
||||
|
font-weight: 500; |
||||
|
} |
||||
|
|
||||
|
.split__items { |
||||
|
list-style: none; |
||||
|
margin: 0; |
||||
|
padding: 0; |
||||
|
} |
||||
|
|
||||
|
.split__item { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
gap: 12px; |
||||
|
padding: 12px 16px; |
||||
|
cursor: pointer; |
||||
|
border-bottom: 1px solid #f1f5f9; |
||||
|
transition: background 0.15s ease; |
||||
|
} |
||||
|
|
||||
|
.split__item:hover { |
||||
|
background: #f8fafc; |
||||
|
} |
||||
|
|
||||
|
.split__item.is-active { |
||||
|
background: rgba(47, 107, 255, 0.08); |
||||
|
box-shadow: inset 3px 0 0 #2f6bff; |
||||
|
} |
||||
|
|
||||
|
.split__value { |
||||
|
font-weight: 700; |
||||
|
color: #475569; |
||||
|
} |
||||
|
|
||||
|
.split__divider { |
||||
|
flex: 0 0 12px; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
cursor: col-resize; |
||||
|
background: #eef1f6; |
||||
|
transition: background 0.15s ease; |
||||
|
} |
||||
|
|
||||
|
.split__divider:hover { |
||||
|
background: #e2e8f0; |
||||
|
} |
||||
|
|
||||
|
.split__divider::before { |
||||
|
content: ''; |
||||
|
width: 4px; |
||||
|
height: 32px; |
||||
|
background-image: radial-gradient(circle, #94a3b8 1.2px, transparent 1.4px); |
||||
|
background-position: center; |
||||
|
background-size: 4px 6px; |
||||
|
background-repeat: repeat-y; |
||||
|
} |
||||
|
|
||||
|
.split--dragging { |
||||
|
user-select: none; |
||||
|
} |
||||
|
|
||||
|
.split--dragging .split__list, |
||||
|
.split--dragging .split__detail { |
||||
|
pointer-events: none; |
||||
|
} |
||||
|
|
||||
|
.split__detail { |
||||
|
flex: 1 1 0; |
||||
|
min-width: 220px; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 16px; |
||||
|
padding: 20px; |
||||
|
overflow: auto; |
||||
|
} |
||||
|
|
||||
|
.split__eyebrow { |
||||
|
font-size: 11px; |
||||
|
font-weight: 700; |
||||
|
text-transform: uppercase; |
||||
|
letter-spacing: 0.08em; |
||||
|
color: #94a3b8; |
||||
|
} |
||||
|
|
||||
|
.split__detail-title { |
||||
|
margin: 2px 0 0; |
||||
|
font-size: 20px; |
||||
|
font-weight: 800; |
||||
|
} |
||||
|
|
||||
|
.split__state { |
||||
|
flex: 1 1 auto; |
||||
|
min-height: 260px; |
||||
|
border: 1px solid #eef1f6; |
||||
|
border-radius: 14px; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.split__state tb-dashboard-state { |
||||
|
display: block; |
||||
|
width: 100%; |
||||
|
height: 100%; |
||||
|
} |
||||
|
|
||||
|
.split__empty { |
||||
|
margin: auto; |
||||
|
color: #94a3b8; |
||||
|
font-size: 14px; |
||||
|
} |
||||
|
{:copy-code} |
||||
|
``` |
||||
|
|
||||
|
<br> |
||||
|
<br> |
||||
@ -0,0 +1,38 @@ |
|||||
|
#### Angular template of resizable split master–detail |
||||
|
|
||||
|
```html |
||||
|
{:code-style="max-height: 400px;"} |
||||
|
<div class="split"> |
||||
|
<aside class="split__list" [style.width.px]="listWidth"> |
||||
|
<h3 class="split__title">Devices</h3> |
||||
|
<ul class="split__items"> |
||||
|
<li *ngFor="let d of devices" |
||||
|
class="split__item" |
||||
|
[class.is-active]="d.entityId === selectedId" |
||||
|
(click)="select(d)"> |
||||
|
<span class="split__name">{{ d.name }}</span> |
||||
|
<span class="split__value">{{ d.lastValue }}</span> |
||||
|
</li> |
||||
|
</ul> |
||||
|
</aside> |
||||
|
|
||||
|
<div class="split__divider" (mousedown)="startDrag($event)"></div> |
||||
|
|
||||
|
<section class="split__detail"> |
||||
|
<ng-container *ngIf="selected; else empty"> |
||||
|
<header> |
||||
|
<div class="split__eyebrow">Device</div> |
||||
|
<h3 class="split__detail-title">{{ selected.name }}</h3> |
||||
|
</header> |
||||
|
<div class="split__state"> |
||||
|
<tb-dashboard-state [ctx]="ctx" stateId="deviceCharts" [syncParentStateParams]="true"></tb-dashboard-state> |
||||
|
</div> |
||||
|
</ng-container> |
||||
|
<ng-template #empty><div class="split__empty">Select a device</div></ng-template> |
||||
|
</section> |
||||
|
</div> |
||||
|
{:copy-code} |
||||
|
``` |
||||
|
|
||||
|
<br> |
||||
|
<br> |
||||
@ -0,0 +1,73 @@ |
|||||
|
#### JavaScript function of resizable split master–detail |
||||
|
|
||||
|
```javascript |
||||
|
{:code-style="max-height: 400px;"} |
||||
|
this.ctx = ctx; |
||||
|
this.devices = []; |
||||
|
this.selected = null; |
||||
|
this.selectedId = null; |
||||
|
this.listWidth = 280; |
||||
|
|
||||
|
this.select = (d) => { |
||||
|
this.selected = d; |
||||
|
this.selectedId = d.entityId; |
||||
|
ctx.stateController.updateState(null, { |
||||
|
entityId: { entityType: d.entityType, id: d.entityId }, |
||||
|
entityName: d.name, |
||||
|
}); |
||||
|
ctx.detectChanges(); |
||||
|
}; |
||||
|
|
||||
|
this.startDrag = (event) => { |
||||
|
event.preventDefault(); |
||||
|
const root = event.currentTarget.closest('.split'); |
||||
|
const left = root.getBoundingClientRect().left; |
||||
|
root.classList.add('split--dragging'); |
||||
|
const onMove = (e) => { |
||||
|
this.listWidth = Math.max(160, Math.min(e.clientX - left, root.clientWidth - 220)); |
||||
|
ctx.detectChanges(); |
||||
|
}; |
||||
|
const onUp = () => { |
||||
|
root.classList.remove('split--dragging'); |
||||
|
window.removeEventListener('mousemove', onMove); |
||||
|
window.removeEventListener('mouseup', onUp); |
||||
|
}; |
||||
|
window.addEventListener('mousemove', onMove); |
||||
|
window.addEventListener('mouseup', onUp); |
||||
|
}; |
||||
|
|
||||
|
const subOpts = { |
||||
|
type: 'latest', |
||||
|
datasources: [ |
||||
|
{ |
||||
|
type: 'entity', |
||||
|
entityFilter: { type: 'deviceType', deviceTypes: ['peopleCount'], deviceNameFilter: '', resolveMultiple: true }, |
||||
|
dataKeys: [{ type: 'timeseries', name: 'peopleCount', settings: {} }], |
||||
|
}, |
||||
|
], |
||||
|
callbacks: { |
||||
|
onDataUpdated: (subscription) => { |
||||
|
this.devices = (subscription.data || []).map((entry) => { |
||||
|
const ds = entry.datasource; |
||||
|
const last = entry.data && entry.data.length ? entry.data[entry.data.length - 1] : null; |
||||
|
return { entityId: ds.entityId, entityType: ds.entityType, name: ds.entityName, lastValue: last ? last[1] : '—' }; |
||||
|
}); |
||||
|
ctx.detectChanges(); |
||||
|
}, |
||||
|
onDataUpdateError: (subscription, e) => console.error(e), |
||||
|
}, |
||||
|
}; |
||||
|
|
||||
|
let subscriptionId = null; |
||||
|
ctx.subscriptionApi.createSubscription(subOpts, true).subscribe((subscription) => { |
||||
|
subscriptionId = subscription.id; |
||||
|
}); |
||||
|
|
||||
|
ctx.registerDestroyCallback(() => { |
||||
|
if (subscriptionId != null) ctx.subscriptionApi.removeSubscription(subscriptionId); |
||||
|
}); |
||||
|
{:copy-code} |
||||
|
``` |
||||
|
|
||||
|
<br> |
||||
|
<br> |
||||
@ -0,0 +1,66 @@ |
|||||
|
#### CSS of composite dashboard with tab navigation |
||||
|
|
||||
|
```css |
||||
|
{:code-style="max-height: 400px;"} |
||||
|
.composite { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 12px; |
||||
|
height: 100%; |
||||
|
box-sizing: border-box; |
||||
|
padding: 12px; |
||||
|
font-family: 'Roboto', system-ui, sans-serif; |
||||
|
} |
||||
|
|
||||
|
.tabs { |
||||
|
display: inline-flex; |
||||
|
gap: 4px; |
||||
|
padding: 4px; |
||||
|
align-self: flex-start; |
||||
|
background: #eef1f6; |
||||
|
border-radius: 10px; |
||||
|
} |
||||
|
|
||||
|
.tab { |
||||
|
border: 0; |
||||
|
background: transparent; |
||||
|
padding: 8px 16px; |
||||
|
border-radius: 7px; |
||||
|
font-size: 13px; |
||||
|
font-weight: 600; |
||||
|
color: #5b6472; |
||||
|
cursor: pointer; |
||||
|
transition: background 0.15s ease, color 0.15s ease; |
||||
|
} |
||||
|
|
||||
|
.tab:hover { |
||||
|
color: #1f2733; |
||||
|
} |
||||
|
|
||||
|
.tab.is-active { |
||||
|
background: #fff; |
||||
|
color: #2f6bff; |
||||
|
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.08); |
||||
|
} |
||||
|
|
||||
|
.container { |
||||
|
flex-grow: 1; |
||||
|
width: 100%; |
||||
|
display: flex; |
||||
|
padding: 0; |
||||
|
border: 1px solid #e6eaf0; |
||||
|
border-radius: 12px; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.state { |
||||
|
flex-grow: 1; |
||||
|
width: 100%; |
||||
|
height: 100%; |
||||
|
padding: 0; |
||||
|
} |
||||
|
{:copy-code} |
||||
|
``` |
||||
|
|
||||
|
<br> |
||||
|
<br> |
||||
@ -0,0 +1,22 @@ |
|||||
|
#### Angular template of composite dashboard with tab navigation |
||||
|
|
||||
|
```html |
||||
|
{:code-style="max-height: 400px;"} |
||||
|
<div class="composite"> |
||||
|
<nav class="tabs"> |
||||
|
<button *ngFor="let tab of tabs" |
||||
|
class="tab" |
||||
|
[class.is-active]="tab.id === activeTab" |
||||
|
(click)="selectTab(tab.id)"> |
||||
|
{{ tab.label }} |
||||
|
</button> |
||||
|
</nav> |
||||
|
<div tb-toast toastTarget="layout" class="container"> |
||||
|
<tb-dashboard-state class="state" [ctx]="ctx" [stateId]="stateId" [syncParentStateParams]="true"></tb-dashboard-state> |
||||
|
</div> |
||||
|
</div> |
||||
|
{:copy-code} |
||||
|
``` |
||||
|
|
||||
|
<br> |
||||
|
<br> |
||||
@ -0,0 +1,35 @@ |
|||||
|
#### JavaScript function of composite dashboard with tab navigation |
||||
|
|
||||
|
```javascript |
||||
|
{:code-style="max-height: 400px;"} |
||||
|
this.ctx = ctx; |
||||
|
|
||||
|
this.tabs = [ |
||||
|
{ id: 'assets', label: 'Assets', stateId: 'assetsState' }, |
||||
|
{ id: 'devices', label: 'Devices', stateId: 'devicesState' }, |
||||
|
{ id: 'customers', label: 'Customers', stateId: 'customersState' }, |
||||
|
]; |
||||
|
|
||||
|
const apply = (subState) => { |
||||
|
const tab = this.tabs.find((t) => t.id === subState) || this.tabs[0]; |
||||
|
this.activeTab = tab.id; |
||||
|
this.stateId = tab.stateId; |
||||
|
}; |
||||
|
|
||||
|
apply(ctx.stateController.getStateParams().subState); |
||||
|
|
||||
|
this.selectTab = (subState) => ctx.stateController.updateState(null, { subState }); |
||||
|
|
||||
|
const stateSub = ctx.stateController.dashboardCtrl.dashboardCtx.stateChanged.subscribe(() => { |
||||
|
apply(ctx.stateController.getStateParams().subState); |
||||
|
ctx.detectChanges(); |
||||
|
}); |
||||
|
|
||||
|
ctx.registerDestroyCallback(() => { |
||||
|
stateSub.unsubscribe(); |
||||
|
}); |
||||
|
{:copy-code} |
||||
|
``` |
||||
|
|
||||
|
<br> |
||||
|
<br> |
||||
@ -0,0 +1,38 @@ |
|||||
|
#### CSS of visitor analytics |
||||
|
|
||||
|
```css |
||||
|
{:code-style="max-height: 400px;"} |
||||
|
.vc { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 16px; |
||||
|
height: 100%; |
||||
|
box-sizing: border-box; |
||||
|
padding: 20px; |
||||
|
background: #fff; |
||||
|
font-family: 'Inter', 'Roboto', system-ui, sans-serif; |
||||
|
color: #0f172a; |
||||
|
} |
||||
|
.vc__head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; } |
||||
|
.vc__title { margin: 0; font-family: 'Roboto', sans-serif; font-size: 20px; font-weight: 500; } |
||||
|
.vc__sub { font-size: 12px; font-weight: 600; color: #94a3b8; } |
||||
|
.vc__controls { display: flex; gap: 10px; flex-wrap: wrap; } |
||||
|
.vc__field { display: flex; flex-direction: column; gap: 4px; font-size: 11px; font-weight: 600; color: #64748b; } |
||||
|
.vc__field input { padding: 7px 10px; border: 1px solid #d8dee9; border-radius: 10px; font-size: 13px; color: #0f172a; } |
||||
|
.vc__field input:focus { outline: none; border-color: #2f6bff; box-shadow: 0 0 0 3px rgba(47, 107, 255, 0.15); } |
||||
|
.vc__kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } |
||||
|
.vc-kpi { display: flex; flex-direction: column; gap: 6px; padding: 14px 16px; border-radius: 14px; background: #f8fafc; border: 1px solid #e6ebf2; } |
||||
|
.vc-kpi__label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #94a3b8; } |
||||
|
.vc-kpi__value { font-size: 26px; font-weight: 800; line-height: 1; color: #1e293b; } |
||||
|
.vc__card { flex: 1; min-height: 240px; display: flex; flex-direction: column; padding: 16px; border: 1px solid #e6ebf2; border-radius: 14px; } |
||||
|
.vc__card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } |
||||
|
.vc__card-title { font-size: 13px; font-weight: 700; color: #334155; } |
||||
|
.vc__back { padding: 6px 12px; border: 1px solid #d8dee9; border-radius: 8px; background: #fff; color: #2f6bff; font-size: 12px; font-weight: 600; cursor: pointer; } |
||||
|
.vc__back[hidden] { display: none; } |
||||
|
.vc__back:hover { background: #f0f5ff; border-color: #2f6bff; } |
||||
|
.vc__chart { flex: 1; min-height: 200px; } |
||||
|
{:copy-code} |
||||
|
``` |
||||
|
|
||||
|
<br> |
||||
|
<br> |
||||
@ -0,0 +1,36 @@ |
|||||
|
#### HTML code of visitor analytics |
||||
|
|
||||
|
```html |
||||
|
{:code-style="max-height: 400px;"} |
||||
|
<div class="vc"> |
||||
|
<header class="vc__head"> |
||||
|
<div> |
||||
|
<h3 class="vc__title">Visitor analytics</h3> |
||||
|
<span class="vc__sub">all peopleCount devices</span> |
||||
|
</div> |
||||
|
<div class="vc__controls"> |
||||
|
<label class="vc__field"><span>From</span><input type="date" id="vcStart" /></label> |
||||
|
<label class="vc__field"><span>To</span><input type="date" id="vcEnd" /></label> |
||||
|
<label class="vc__field"><span>Open</span><input type="time" id="vcOpen" value="09:00" /></label> |
||||
|
<label class="vc__field"><span>Close</span><input type="time" id="vcClose" value="18:00" /></label> |
||||
|
</div> |
||||
|
</header> |
||||
|
<div class="vc__kpis"> |
||||
|
<div class="vc-kpi"><span class="vc-kpi__label">Total visitors</span><span class="vc-kpi__value" id="vcTotal">—</span></div> |
||||
|
<div class="vc-kpi"><span class="vc-kpi__label">Busiest day</span><span class="vc-kpi__value" id="vcPeakDay">—</span></div> |
||||
|
<div class="vc-kpi"><span class="vc-kpi__label">Peak hour</span><span class="vc-kpi__value" id="vcPeakHour">—</span></div> |
||||
|
<div class="vc-kpi"><span class="vc-kpi__label">Daily average</span><span class="vc-kpi__value" id="vcAvg">—</span></div> |
||||
|
</div> |
||||
|
<div class="vc__card"> |
||||
|
<div class="vc__card-head"> |
||||
|
<span class="vc__card-title" id="vcChartTitle">Visitors by day</span> |
||||
|
<button class="vc__back" id="vcBack" hidden>← Back</button> |
||||
|
</div> |
||||
|
<div class="vc__chart" id="vcChart"></div> |
||||
|
</div> |
||||
|
</div> |
||||
|
{:copy-code} |
||||
|
``` |
||||
|
|
||||
|
<br> |
||||
|
<br> |
||||
@ -0,0 +1,185 @@ |
|||||
|
#### JavaScript function of visitor analytics |
||||
|
|
||||
|
```javascript |
||||
|
{:code-style="max-height: 400px;"} |
||||
|
// Plain HTML mode: `container` is the widget's DOM; ECharts comes from the CDN. |
||||
|
const echarts = window.echarts; |
||||
|
const el = (sel) => container.querySelector(sel); |
||||
|
const pad = (n) => String(n).padStart(2, '0'); |
||||
|
const isoDate = (d) => d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); |
||||
|
const dayLabel = (d) => d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); |
||||
|
const minutes = (v) => { |
||||
|
const [h, m] = (v || '0:0').split(':').map(Number); |
||||
|
return h * 60 + m; |
||||
|
}; |
||||
|
const palette = ['#2563eb', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#0ea5e9']; |
||||
|
|
||||
|
const chart = echarts.init(el('#vcChart')); |
||||
|
chart.setOption({ |
||||
|
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, |
||||
|
legend: { top: 0 }, |
||||
|
grid: { left: 8, right: 16, top: 40, bottom: 8, containLabel: true }, |
||||
|
xAxis: { type: 'category', data: [], axisLabel: { hideOverlap: true } }, |
||||
|
yAxis: { type: 'value' }, |
||||
|
series: [], |
||||
|
}); |
||||
|
const resizeObserver = new window.ResizeObserver(() => chart.resize()); |
||||
|
resizeObserver.observe(el('#vcChart')); |
||||
|
|
||||
|
let lastEntries = []; |
||||
|
let mode = 'daily'; |
||||
|
let selectedDate = null; |
||||
|
let dailyDates = []; // iso date per daily x-index, for drill-down |
||||
|
|
||||
|
// Click a day → drill into its hourly breakdown. |
||||
|
chart.on('click', (p) => { |
||||
|
if (mode !== 'daily' || p.dataIndex == null || dailyDates[p.dataIndex] == null) return; |
||||
|
selectedDate = dailyDates[p.dataIndex]; |
||||
|
mode = 'hourly'; |
||||
|
el('#vcBack').hidden = false; |
||||
|
recompute(); |
||||
|
}); |
||||
|
el('#vcBack').addEventListener('click', () => { |
||||
|
mode = 'daily'; |
||||
|
selectedDate = null; |
||||
|
el('#vcBack').hidden = true; |
||||
|
recompute(); |
||||
|
}); |
||||
|
|
||||
|
// One stacked series per device, summing values into the given bucket index. |
||||
|
function buildSeries(labelCount, bucketOf) { |
||||
|
return lastEntries.map((entry, k) => { |
||||
|
const arr = new Array(labelCount).fill(0); |
||||
|
(entry.data || []).forEach(([ts, value]) => { |
||||
|
const i = bucketOf(new Date(Number(ts))); |
||||
|
if (i != null && i >= 0) arr[i] += Number(value); |
||||
|
}); |
||||
|
return { name: entry.datasource.entityName, type: 'bar', stack: 'v', data: arr, itemStyle: { color: palette[k % palette.length] } }; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function recompute() { |
||||
|
const open = minutes(el('#vcOpen').value); |
||||
|
const close = minutes(el('#vcClose').value); |
||||
|
const inHours = (d) => { |
||||
|
const m = d.getHours() * 60 + d.getMinutes(); |
||||
|
return m >= open && m < close; |
||||
|
}; |
||||
|
|
||||
|
// KPIs over the whole range (per day, summed across devices). |
||||
|
const start = new Date(el('#vcStart').value + 'T00:00:00'); |
||||
|
const end = new Date(el('#vcEnd').value + 'T00:00:00'); |
||||
|
const dayIndex = {}; |
||||
|
const dayLabels = []; |
||||
|
dailyDates = []; |
||||
|
for (let t = new Date(start); t <= end; t.setDate(t.getDate() + 1)) { |
||||
|
dayIndex[isoDate(t)] = dayLabels.length; |
||||
|
dailyDates.push(isoDate(t)); |
||||
|
dayLabels.push(dayLabel(t)); |
||||
|
} |
||||
|
const dayTotals = dayLabels.map(() => 0); |
||||
|
const perHour = {}; |
||||
|
let total = 0; |
||||
|
lastEntries.forEach((entry) => |
||||
|
(entry.data || []).forEach(([ts, value]) => { |
||||
|
const d = new Date(Number(ts)); |
||||
|
if (!inHours(d)) return; |
||||
|
const i = dayIndex[isoDate(d)]; |
||||
|
if (i === undefined) return; |
||||
|
const v = Number(value); |
||||
|
dayTotals[i] += v; |
||||
|
perHour[d.getHours()] = (perHour[d.getHours()] || 0) + v; |
||||
|
total += v; |
||||
|
}), |
||||
|
); |
||||
|
el('#vcTotal').textContent = total; |
||||
|
el('#vcPeakDay').textContent = total ? dayLabels[dayTotals.indexOf(Math.max(...dayTotals))] : '—'; |
||||
|
const ph = Object.keys(perHour).sort((a, b) => perHour[b] - perHour[a])[0]; |
||||
|
el('#vcPeakHour').textContent = ph != null ? ph + ':00' : '—'; |
||||
|
el('#vcAvg').textContent = Math.round(total / (dayTotals.filter((x) => x > 0).length || 1)); |
||||
|
|
||||
|
// Chart: daily (stacked by device) or the hourly breakdown of the selected day. |
||||
|
if (mode === 'daily') { |
||||
|
el('#vcChartTitle').textContent = 'Visitors by day'; |
||||
|
const series = buildSeries(dayLabels.length, (d) => (inHours(d) ? dayIndex[isoDate(d)] : null)); |
||||
|
chart.setOption({ xAxis: { data: dayLabels }, series }, { replaceMerge: ['series'] }); |
||||
|
} else { |
||||
|
const h0 = Math.floor(open / 60); |
||||
|
const h1 = Math.ceil(close / 60); |
||||
|
const labels = []; |
||||
|
for (let h = h0; h < h1; h++) labels.push(pad(h) + ':00'); |
||||
|
el('#vcChartTitle').textContent = 'Visitors by hour — ' + dayLabel(new Date(selectedDate + 'T00:00:00')); |
||||
|
const series = buildSeries(labels.length, (d) => |
||||
|
isoDate(d) === selectedDate && inHours(d) ? d.getHours() - h0 : null, |
||||
|
); |
||||
|
chart.setOption({ xAxis: { data: labels }, series }, { replaceMerge: ['series'] }); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// (Re)subscribe for the chosen date range (a fixed history window). |
||||
|
let subscriptionId = null; |
||||
|
function subscribe() { |
||||
|
if (subscriptionId != null) { |
||||
|
ctx.subscriptionApi.removeSubscription(subscriptionId); |
||||
|
subscriptionId = null; |
||||
|
} |
||||
|
const startTimeMs = new Date(el('#vcStart').value + 'T00:00:00').getTime(); |
||||
|
const endTimeMs = new Date(el('#vcEnd').value + 'T23:59:59').getTime(); |
||||
|
const subOpts = { |
||||
|
type: 'timeseries', |
||||
|
useDashboardTimewindow: false, |
||||
|
timeWindowConfig: { |
||||
|
selectedTab: 1, |
||||
|
history: { historyType: 1, fixedTimewindow: { startTimeMs, endTimeMs } }, |
||||
|
aggregation: { type: 'NONE', limit: 50000 }, |
||||
|
}, |
||||
|
datasources: [ |
||||
|
{ |
||||
|
type: 'entity', |
||||
|
entityFilter: { type: 'deviceType', deviceTypes: ['peopleCount'], deviceNameFilter: '', resolveMultiple: true }, |
||||
|
dataKeys: [{ type: 'timeseries', name: 'peopleCount', settings: {} }], |
||||
|
}, |
||||
|
], |
||||
|
callbacks: { |
||||
|
onDataUpdated: (subscription) => { |
||||
|
lastEntries = subscription.data || []; |
||||
|
recompute(); |
||||
|
}, |
||||
|
onDataUpdateError: (subscription, e) => console.error(e), |
||||
|
}, |
||||
|
}; |
||||
|
ctx.subscriptionApi.createSubscription(subOpts, true).subscribe((subscription) => { |
||||
|
subscriptionId = subscription.id; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Changing the range resets the drill-down and re-subscribes; hours just re-filter. |
||||
|
function onRangeChange() { |
||||
|
mode = 'daily'; |
||||
|
selectedDate = null; |
||||
|
el('#vcBack').hidden = true; |
||||
|
subscribe(); |
||||
|
} |
||||
|
|
||||
|
// Defaults: the current month (1st → today). |
||||
|
const now = new Date(); |
||||
|
el('#vcEnd').value = isoDate(now); |
||||
|
el('#vcStart').value = isoDate(new Date(now.getFullYear(), now.getMonth(), 1)); |
||||
|
|
||||
|
el('#vcStart').addEventListener('change', onRangeChange); |
||||
|
el('#vcEnd').addEventListener('change', onRangeChange); |
||||
|
el('#vcOpen').addEventListener('change', recompute); |
||||
|
el('#vcClose').addEventListener('change', recompute); |
||||
|
subscribe(); |
||||
|
|
||||
|
// The JS re-runs on every reload — release the subscription, observer and chart. |
||||
|
ctx.registerDestroyCallback(() => { |
||||
|
if (subscriptionId != null) ctx.subscriptionApi.removeSubscription(subscriptionId); |
||||
|
resizeObserver.disconnect(); |
||||
|
chart.dispose(); |
||||
|
}); |
||||
|
{:copy-code} |
||||
|
``` |
||||
|
|
||||
|
<br> |
||||
|
<br> |
||||
@ -0,0 +1,12 @@ |
|||||
|
#### Resources of visitor analytics |
||||
|
|
||||
|
Add the following JavaScript URL under the <b>Resources</b> tab. It loads <a href="https://echarts.apache.org/" target="_blank">ECharts</a> before your function runs and exposes the <code>echarts</code> global (accessed as <code>window.echarts</code>): |
||||
|
|
||||
|
```text |
||||
|
{:code-style="max-height: 400px;"} |
||||
|
https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js |
||||
|
{:copy-code} |
||||
|
``` |
||||
|
|
||||
|
<br> |
||||
|
<br> |
||||
@ -0,0 +1,45 @@ |
|||||
|
#### CSS of kanban board |
||||
|
|
||||
|
```css |
||||
|
{:code-style="max-height: 400px;"} |
||||
|
.kanban { |
||||
|
display: flex; |
||||
|
gap: 12px; |
||||
|
height: 100%; |
||||
|
box-sizing: border-box; |
||||
|
padding: 16px; |
||||
|
overflow-x: auto; |
||||
|
background: #f9fafb; |
||||
|
font-family: 'Inter', 'Roboto', system-ui, sans-serif; |
||||
|
color: #111827; |
||||
|
font-size: 13px; |
||||
|
} |
||||
|
.kanban__col { flex: 1 1 0; min-width: 220px; display: flex; flex-direction: column; background: #f3f4f6; border-radius: 10px; } |
||||
|
.kanban__head { display: flex; align-items: center; gap: 8px; padding: 12px 14px 8px; } |
||||
|
.kanban__title { font-size: 12px; font-weight: 600; color: #374151; } |
||||
|
.kanban__count { margin-left: auto; min-width: 20px; height: 20px; padding: 0 6px; display: inline-flex; align-items: center; justify-content: center; border-radius: 999px; background: #e5e7eb; color: #6b7280; font-size: 11px; font-weight: 600; } |
||||
|
.kanban__cards { display: flex; flex-direction: column; gap: 8px; padding: 4px 8px 12px; flex: 1; min-height: 24px; overflow-y: auto; } |
||||
|
.kanban__card { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 8px; |
||||
|
padding: 10px 12px; |
||||
|
background: #fff; |
||||
|
border: 1px solid #e5e7eb; |
||||
|
border-radius: 8px; |
||||
|
cursor: grab; |
||||
|
transition: border-color 0.12s ease, box-shadow 0.12s ease; |
||||
|
} |
||||
|
.kanban__card:hover { border-color: #d1d5db; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); } |
||||
|
.kanban__name { font-weight: 500; color: #111827; } |
||||
|
.kanban__dot { width: 8px; height: 8px; border-radius: 50%; flex: none; } |
||||
|
.kanban__dot--idle { background: #9ca3af; } |
||||
|
.kanban__dot--active { background: #22c55e; } |
||||
|
.kanban__dot--maintenance { background: #f59e0b; } |
||||
|
.kanban__card.sortable-ghost { opacity: 0; } |
||||
|
.kanban__card.sortable-chosen { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); } |
||||
|
{:copy-code} |
||||
|
``` |
||||
|
|
||||
|
<br> |
||||
|
<br> |
||||
@ -0,0 +1,10 @@ |
|||||
|
#### HTML code of kanban board |
||||
|
|
||||
|
```html |
||||
|
{:code-style="max-height: 400px;"} |
||||
|
<div class="kanban" id="board"></div> |
||||
|
{:copy-code} |
||||
|
``` |
||||
|
|
||||
|
<br> |
||||
|
<br> |
||||
@ -0,0 +1,89 @@ |
|||||
|
#### JavaScript function of kanban board |
||||
|
|
||||
|
```javascript |
||||
|
{:code-style="max-height: 400px;"} |
||||
|
// Plain HTML mode: `container` is the widget's DOM. Build a status board and, |
||||
|
// on drop, persist the card's new column (status) back to the device. |
||||
|
const Sortable = window.Sortable; // loaded from the CDN added under Resources |
||||
|
const statuses = ['idle', 'active', 'maintenance']; |
||||
|
const labels = { idle: 'Idle', active: 'Active', maintenance: 'Maintenance' }; |
||||
|
|
||||
|
const board = container.querySelector('#board'); |
||||
|
let devices = []; // { id, entityType, name, status } |
||||
|
|
||||
|
// Escape untrusted strings (e.g. device names) before putting them in innerHTML. |
||||
|
const esc = (s) => |
||||
|
String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); |
||||
|
|
||||
|
function render() { |
||||
|
board.innerHTML = statuses |
||||
|
.map((s) => { |
||||
|
const items = devices.filter((d) => d.status === s); |
||||
|
const cards = items |
||||
|
.map( |
||||
|
(d) => |
||||
|
`<div class="kanban__card" data-id="${esc(d.id)}" data-type="${esc(d.entityType)}"><span class="kanban__dot kanban__dot--${s}"></span><span class="kanban__name">${esc(d.name)}</span></div>`, |
||||
|
) |
||||
|
.join(''); |
||||
|
return `<section class="kanban__col"> |
||||
|
<header class="kanban__head"><span class="kanban__dot kanban__dot--${s}"></span><span class="kanban__title">${labels[s]}</span><span class="kanban__count">${items.length}</span></header> |
||||
|
<div class="kanban__cards" data-status="${s}">${cards}</div> |
||||
|
</section>`; |
||||
|
}) |
||||
|
.join(''); |
||||
|
|
||||
|
// Make each column a Sortable list; cards share one group so they move between columns. |
||||
|
board.querySelectorAll('.kanban__cards').forEach((listEl) => { |
||||
|
new Sortable(listEl, { |
||||
|
group: 'kanban', |
||||
|
animation: 150, |
||||
|
onAdd: (evt) => { |
||||
|
const id = evt.item.dataset.id; |
||||
|
const entityType = evt.item.dataset.type; |
||||
|
const status = evt.to.dataset.status; // destination column |
||||
|
const dev = devices.find((d) => d.id === id); |
||||
|
if (dev) dev.status = status; |
||||
|
ctx.attributeService |
||||
|
.saveEntityAttributes({ id, entityType }, 'SERVER_SCOPE', [{ key: 'status', value: status }]) |
||||
|
.subscribe(); |
||||
|
}, |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Live data: devices of type "machine" with their "status" attribute. |
||||
|
const subOpts = { |
||||
|
type: 'latest', |
||||
|
datasources: [ |
||||
|
{ |
||||
|
type: 'entity', |
||||
|
entityFilter: { type: 'deviceType', deviceTypes: ['machine'], deviceNameFilter: '', resolveMultiple: true }, |
||||
|
dataKeys: [{ type: 'attribute', name: 'status', settings: {} }], |
||||
|
}, |
||||
|
], |
||||
|
callbacks: { |
||||
|
onDataUpdated: (subscription) => { |
||||
|
devices = (subscription.data || []).map((entry) => { |
||||
|
const ds = entry.datasource; |
||||
|
const last = entry.data && entry.data.length ? entry.data[entry.data.length - 1] : null; |
||||
|
const status = last ? String(last[1]) : 'idle'; |
||||
|
return { id: ds.entityId, entityType: ds.entityType, name: ds.entityName, status: statuses.includes(status) ? status : 'idle' }; |
||||
|
}); |
||||
|
render(); |
||||
|
}, |
||||
|
onDataUpdateError: (subscription, e) => console.error(e), |
||||
|
}, |
||||
|
}; |
||||
|
// The JS re-runs on every reload — capture the id and remove the subscription on destroy. |
||||
|
let subscriptionId = null; |
||||
|
ctx.subscriptionApi.createSubscription(subOpts, true).subscribe((subscription) => { |
||||
|
subscriptionId = subscription.id; |
||||
|
}); |
||||
|
ctx.registerDestroyCallback(() => { |
||||
|
if (subscriptionId != null) ctx.subscriptionApi.removeSubscription(subscriptionId); |
||||
|
}); |
||||
|
{:copy-code} |
||||
|
``` |
||||
|
|
||||
|
<br> |
||||
|
<br> |
||||
@ -0,0 +1,12 @@ |
|||||
|
#### Resources of kanban board |
||||
|
|
||||
|
Add the following JavaScript URL under the <b>Resources</b> tab. It loads <a href="https://sortablejs.github.io/Sortable/" target="_blank">SortableJS</a> before your function runs and exposes the <code>Sortable</code> global (accessed as <code>window.Sortable</code>): |
||||
|
|
||||
|
```text |
||||
|
{:code-style="max-height: 400px;"} |
||||
|
https://cdn.jsdelivr.net/npm/sortablejs@1/Sortable.min.js |
||||
|
{:copy-code} |
||||
|
``` |
||||
|
|
||||
|
<br> |
||||
|
<br> |
||||
Loading…
Reference in new issue