Share your best Widget

With the new HTML Widget it was never easier to build useful widgets, especially if you are using the AI Coding Assistant from the AI Agent Manager (now public preview :tada:). However, not everyone can enable the AI Agent Manager because of company policies or data-protection concerns. Therefore I would like to use this thread to share the best creations.

Please use the following format:

**Prompt:** <<your prompt>>
**Screenshot:**
<<screenshot of the widget>>

<<optional-description>>

**Code:**
<details>
\```

\```
</details>

How to use shared widget:
Go to your dashboard, select the HTML Widget and enable the โ€œadvanced developer modeโ€. Then copy the code from the โ€œdetailsโ€ view inside the code-text-input in the HTML Widget config view.

I will start with my Spark-Line-KPI-Widget

Prompt: Create a card layout showing all the latest measurements of the current device. The measurements should update automatically. Underneath the measurements, there should be a spark-line in different pastel colors showing the recent trends.

Screenshot:

Sadly the measurements in this example are simulated. But I like the โ€œspark-linesโ€ which indicates the latest measurements trends.

Code:

``` import { LitElement, html, css } from 'lit'; import { styleImports } from 'styles'; import { fetch } from 'fetch';

const SPARKLINE_COLORS = [
โ€˜#a8c9f8โ€™,
โ€˜#f9bfa8โ€™,
โ€˜#a8f0c6โ€™,
โ€˜#d4a8f9โ€™,
โ€˜#f9e8a8โ€™,
โ€˜#a8f4f0โ€™,
โ€˜#f9a8bbโ€™,
โ€˜#c8e8a8โ€™,
โ€˜#f9a8e0โ€™,
โ€˜#a8c4f9โ€™,
โ€˜#f9cda8โ€™,
โ€˜#a8f9c8โ€™,
];

export default class CurrentMeasurementWidget extends LitElement {
static styles = css`
:host > div {
padding: 24px 28px 28px 28px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}

.widget-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 20px;
  padding-bottom: 14px;
  border-bottom: 1px solid var(--c8y-root-component-separator-color);
  flex-shrink: 0;
}

.widget-title {
  font-size: 16px;
  font-weight: 600;
  color: var(--text-color);
  margin: 0;
}

.widget-subtitle {
  font-size: 12px;
  color: var(--text-muted);
  margin: 4px 0 0 0;
}

.header-actions {
  display: flex;
  align-items: center;
  gap: 8px;
}

.last-updated {
  font-size: 11px;
  color: var(--text-muted);
}

.metrics-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 16px;
  flex: 1;
  align-content: start;
  overflow-y: auto;
  padding: 2px 0 4px 0;
}

.metric-card {
  background-color: var(--body-background-color);
  border: 1px solid var(--c8y-root-component-border-color);
  border-radius: var(--c8y-root-component-border-radius-base);
  padding: 16px 16px 12px 16px;
  display: flex;
  flex-direction: column;
  gap: 4px;
  transition: box-shadow 0.15s ease;
}

.metric-card:hover {
  box-shadow: 0 2px 8px rgba(0,0,0,0.07);
}

.metric-type {
  font-size: 11px;
  color: var(--text-muted);
  text-transform: uppercase;
  letter-spacing: 0.5px;
  font-weight: 600;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.metric-series {
  font-size: 11px;
  color: var(--text-muted);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.metric-value-row {
  display: flex;
  align-items: baseline;
  gap: 6px;
  margin-top: 6px;
}

.metric-value {
  font-size: 28px;
  font-weight: 700;
  color: var(--text-color);
  line-height: 1;
}

.metric-unit {
  font-size: 14px;
  font-weight: 500;
  color: var(--text-muted);
}

.metric-time {
  font-size: 11px;
  color: var(--text-muted);
  margin-top: 4px;
}

.sparkline-container {
  margin-top: 10px;
  position: relative;
  height: 44px;
}

.sparkline-container svg {
  width: 100%;
  height: 44px;
  display: block;
  overflow: visible;
}

.sparkline-loading {
  height: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
  margin-top: 10px;
}

.sparkline-no-data {
  height: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
  margin-top: 10px;
  font-size: 10px;
  color: var(--text-muted);
}

.sparkline-range {
  display: flex;
  justify-content: space-between;
  font-size: 10px;
  color: var(--text-muted);
  margin-top: 2px;
}

.loading-container {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
}

.empty-state {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: 40px 20px;
  color: var(--text-muted);
}

.empty-state h4 {
  margin: 0 0 6px 0;
  font-size: 16px;
  color: var(--text-color);
}

.empty-state p {
  margin: 0;
  font-size: 13px;
}

.error-container {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
}

.refreshing-indicator {
  display: inline-block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background-color: #a8c9f8;
  animation: pulse 1.2s ease-in-out infinite;
}

@keyframes pulse {
  0%, 100% { opacity: 1; transform: scale(1); }
  50% { opacity: 0.4; transform: scale(0.7); }
}

.mini-spinner {
  width: 14px;
  height: 14px;
  border: 2px solid var(--c8y-root-component-border-color);
  border-top-color: #a8c9f8;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
  display: inline-block;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

.metric-type-row {
  display: flex;
  align-items: center;
  overflow: hidden;
}

`;

static properties = {
c8yContext: { type: Object },
measurements: { type: Array },
sparklines: { type: Object },
loading: { type: Boolean },
error: { type: String },
lastUpdated: { type: String },
refreshing: { type: Boolean },
};

constructor() {
super();
this.measurements = ;
this.sparklines = {};
this.loading = true;
this.error = null;
this.lastUpdated = null;
this.refreshing = false;
this._refreshTimer = null;
this._colorMap = {};
this._colorIndex = 0;
}

_getColor(key) {
if (!this._colorMap[key]) {
this._colorMap[key] = SPARKLINE_COLORS[this._colorIndex % SPARKLINE_COLORS.length];
this._colorIndex++;
}
return this._colorMap[key];
}

async connectedCallback() {
super.connectedCallback();
await this.loadMeasurements();
this._refreshTimer = setInterval(() => this.refreshMeasurements(), 10000);
}

disconnectedCallback() {
super.disconnectedCallback();
if (this._refreshTimer) {
clearInterval(this._refreshTimer);
this._refreshTimer = null;
}
}

async loadMeasurements() {
if (!this.c8yContext?.id) {
this.loading = false;
this.error = โ€˜No device selectedโ€™;
return;
}

this.loading = true;
this.error = null;

try {
  const data = await this._fetchLatestMeasurements(this.c8yContext.id);
  data.forEach(m => this._getColor(`${m.fragment}__${m.series}`));
  this.measurements = data;
  this.lastUpdated = new Date().toLocaleTimeString();
  await this.updateComplete;
  this._loadAllSparklines();
} catch (err) {
  console.error('Failed to load measurements:', err);
  this.error = 'Failed to load measurements: ' + err.message;
} finally {
  this.loading = false;
}

}

async refreshMeasurements() {
if (!this.c8yContext?.id || this.loading) return;
this.refreshing = true;
try {
const data = await this._fetchLatestMeasurements(this.c8yContext.id);
data.forEach(m => this._getColor(${m.fragment}__${m.series}));
this.measurements = data;
this.lastUpdated = new Date().toLocaleTimeString();
this._loadAllSparklines();
} catch (err) {
console.error(โ€˜Refresh failed:โ€™, err);
} finally {
this.refreshing = false;
}
}

async _loadAllSparklines() {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
const dateFrom = oneHourAgo.toISOString();
const dateTo = now.toISOString();

const newSparklines = { ...this.sparklines };

const promises = this.measurements.map(async (m) => {
  const key = `${m.fragment}__${m.series}`;
  newSparklines[key] = { loading: true, points: null };
  this.sparklines = { ...newSparklines };

  try {
    const points = await this._fetchSparklineData(
      this.c8yContext.id,
      m.type,
      m.fragment,
      m.series,
      dateFrom,
      dateTo
    );
    newSparklines[key] = { loading: false, points };
  } catch (e) {
    newSparklines[key] = { loading: false, points: [] };
  }
  this.sparklines = { ...newSparklines };
});

await Promise.all(promises);

}

async _fetchSparklineData(deviceId, type, fragment, series, dateFrom, dateTo) {
const url = /measurement/measurements?source=${deviceId}&type=${encodeURIComponent(type)}&dateFrom=${encodeURIComponent(dateFrom)}&dateTo=${encodeURIComponent(dateTo)}&pageSize=100&revert=false;
const response = await fetch(url);
if (!response.ok) return ;
const data = await response.json();
const measurements = data.measurements || ;

const points = [];
for (const m of measurements) {
  if (m[fragment] && m[fragment][series] !== undefined) {
    const val = parseFloat(m[fragment][series].value);
    if (!isNaN(val)) {
      points.push({ t: new Date(m.time).getTime(), v: val });
    }
  }
}
points.sort((a, b) => a.t - b.t);
return points;

}

async _fetchLatestMeasurements(deviceId) {
const response = await fetch(
/measurement/measurements?source=${deviceId}&pageSize=50&revert=true
);
if (!response.ok) {
throw new Error(HTTP ${response.status});
}
const data = await response.json();
const measurements = data.measurements || ;

const latestMap = new Map();

for (const m of measurements) {
  for (const key of Object.keys(m)) {
    if (key.startsWith('self') || key === 'id' || key === 'type' ||
        key === 'source' || key === 'time') continue;

    const fragment = m[key];
    if (typeof fragment !== 'object' || fragment === null) continue;

    for (const seriesKey of Object.keys(fragment)) {
      const series = fragment[seriesKey];
      if (typeof series !== 'object' || series === null) continue;
      if (series.value === undefined) continue;

      const mapKey = `${key}__${seriesKey}`;
      if (!latestMap.has(mapKey)) {
        latestMap.set(mapKey, {
          type: m.type || key,
          fragment: key,
          series: seriesKey,
          value: series.value,
          unit: series.unit || '',
          time: m.time,
        });
      }
    }
  }
}

return Array.from(latestMap.values());

}

_buildSparklineSvg(points) {
if (!points || points.length < 2) return null;

const W = 200;
const H = 40;
const PAD_X = 2;
const PAD_Y = 3;

const values = points.map(p => p.v);
const minV = Math.min(...values);
const maxV = Math.max(...values);
const rangeV = maxV - minV || 1;

const times = points.map(p => p.t);
const minT = Math.min(...times);
const maxT = Math.max(...times);
const rangeT = maxT - minT || 1;

const toX = t => PAD_X + ((t - minT) / rangeT) * (W - PAD_X * 2);
const toY = v => PAD_Y + (1 - (v - minV) / rangeV) * (H - PAD_Y * 2);

const coords = points.map(p => ({ x: toX(p.t), y: toY(p.v) }));

let pathD = `M ${coords[0].x} ${coords[0].y}`;
for (let i = 1; i < coords.length; i++) {
  const prev = coords[i - 1];
  const curr = coords[i];
  const cx = (prev.x + curr.x) / 2;
  pathD += ` Q ${cx} ${prev.y} ${curr.x} ${curr.y}`;
}

const lastCoord = coords[coords.length - 1];
const firstCoord = coords[0];
const areaD = pathD +
  ` L ${lastCoord.x} ${H} L ${firstCoord.x} ${H} Z`;

return {
  pathD,
  areaD,
  lastX: lastCoord.x,
  lastY: lastCoord.y,
  minV,
  maxV,
  W,
  H,
};

}

_hexToRgba(hex, alpha) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return rgba(${r}, ${g}, ${b}, ${alpha});
}

_renderSparkline(key, color) {
const spark = this.sparklines[key];

if (!spark || spark.loading) {
  return html`
    <div class="sparkline-loading">
      <div class="mini-spinner"></div>
    </div>
  `;
}

if (!spark.points || spark.points.length < 2) {
  return html`
    <div class="sparkline-no-data">No history in last hour</div>
  `;
}

const svg = this._buildSparklineSvg(spark.points);
if (!svg) return html``;

const gradientId = `spark-grad-${key.replace(/[^a-zA-Z0-9]/g, '_')}`;
const fillTop = this._hexToRgba(color, 0.4);
const fillBot = this._hexToRgba(color, 0.05);

return html`
  <div class="sparkline-container">
    <svg viewBox="0 0 ${svg.W} ${svg.H}" preserveAspectRatio="none" aria-hidden="true">
      <defs>
        <linearGradient id="${gradientId}" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stop-color="${fillTop}"/>
          <stop offset="100%" stop-color="${fillBot}"/>
        </linearGradient>
      </defs>
      <path
        d="${svg.areaD}"
        fill="url(#${gradientId})"
        stroke="none"
      />
      <path
        d="${svg.pathD}"
        fill="none"
        stroke="${color}"
        stroke-width="1.8"
        stroke-linecap="round"
        stroke-linejoin="round"
      />
      <circle
        cx="${svg.lastX}"
        cy="${svg.lastY}"
        r="3"
        fill="${color}"
      />
    </svg>
  </div>
  <div class="sparkline-range">
    <span>${this._formatValue(svg.minV)}</span>
    <span>1h</span>
    <span>${this._formatValue(svg.maxV)}</span>
  </div>
`;

}

_formatValue(value) {
if (value === null || value === undefined) return โ€˜โ€“โ€™;
const num = parseFloat(value);
if (isNaN(num)) return String(value);
if (Math.abs(num) >= 1000) return num.toFixed(0);
if (Math.abs(num) >= 100) return num.toFixed(1);
if (Math.abs(num) >= 10) return num.toFixed(2);
return num.toFixed(2);
}

_formatTime(isoString) {
if (!isoString) return โ€˜โ€™;
try {
return new Date(isoString).toLocaleString();
} catch {
return isoString;
}
}

formatLabel(fragment) {
return fragment
.replace(/^c8y
/, โ€˜โ€™)
.replace(/([A-Z])/g, โ€™ $1โ€™)
.trim();
}

render() {
const deviceName = this.c8yContext?.name || โ€˜Unknown Deviceโ€™;

if (this.loading) {
  return html`
    <style>${styleImports}</style>
    <div>
      <div class="widget-header">
        <div>
          <p class="widget-title">Current Measurements</p>
          <p class="widget-subtitle">${deviceName}</p>
        </div>
      </div>
      <div class="loading-container">
        <div class="spinner">
          <div class="rect1"></div>
          <div class="rect2"></div>
          <div class="rect3"></div>
          <div class="rect4"></div>
          <div class="rect5"></div>
        </div>
      </div>
    </div>
  `;
}

if (this.error && this.measurements.length === 0) {
  return html`
    <style>${styleImports}</style>
    <div>
      <div class="widget-header">
        <div>
          <p class="widget-title">Current Measurements</p>
          <p class="widget-subtitle">${deviceName}</p>
        </div>
      </div>
      <div class="error-container">
        <div class="alert alert-danger" role="alert">
          <strong>Error:</strong> ${this.error}
        </div>
      </div>
    </div>
  `;
}

if (!this.error && this.measurements.length === 0) {
  return html`
    <style>${styleImports}</style>
    <div>
      <div class="widget-header">
        <div>
          <p class="widget-title">Current Measurements</p>
          <p class="widget-subtitle">${deviceName}</p>
        </div>
      </div>
      <div class="empty-state">
        <h4>No Measurements Found</h4>
        <p>No measurement data is available for "${deviceName}".</p>
      </div>
    </div>
  `;
}

return html`
  <style>${styleImports}</style>
  <div>
    <div class="widget-header">
      <div>
        <p class="widget-title">Current Measurements</p>
        <p class="widget-subtitle">${deviceName}</p>
      </div>
      <div class="header-actions">
        ${this.refreshing
          ? html`<span class="refreshing-indicator" title="Refreshing..."></span>`
          : ''}
        ${this.lastUpdated
          ? html`<span class="last-updated">Updated: ${this.lastUpdated}</span>`
          : ''}
      </div>
    </div>

    <div class="metrics-grid">
      ${this.measurements.map(m => {
        const key = `${m.fragment}__${m.series}`;
        const color = this._getColor(key);
        return html`
          <div class="metric-card" style="border-top: 3px solid ${color};">
            <div class="metric-type-row">
              <span class="metric-type" title="${this._formatLabel(m.fragment)}">
                ${this._formatLabel(m.fragment)}
              </span>
            </div>
            <div class="metric-series" title="${m.series}">
              Series: ${m.series}
            </div>
            <div class="metric-value-row">
              <span class="metric-value">${this._formatValue(m.value)}</span>
              ${m.unit ? html`<span class="metric-unit">${m.unit}</span>` : ''}
            </div>
            <div class="metric-time" title="${this._formatTime(m.time)}">
              ${this._formatTime(m.time)}
            </div>
            ${this._renderSparkline(key, color)}
          </div>
        `;
      })}
    </div>
  </div>
`;

}
}

</details>
7 Likes

Prompt: Create a widget that shows a track on a map with speed and vibration measurements between each GPS point visualized

Screenshot:

Visualized the Vibrations a train cargo wagon is experiencing during a journey. (Data is simulated)

Code:

import { LitElement, html, css } from โ€˜litโ€™;
import { styleImports } from โ€˜stylesโ€™;
import { fetch } from โ€˜fetchโ€™;

const SPEED_PALETTE = [
{ max: 200, color: โ€˜#3cb44bโ€™, label: โ€˜< 200โ€™ },
{ max: 400, color: โ€˜#ffe119โ€™, label: โ€˜200-400โ€™ },
{ max: 600, color: โ€˜#f58231โ€™, label: โ€˜400-600โ€™ },
{ max: Infinity, color: โ€˜#e6194bโ€™, label: โ€˜> 600โ€™ }
];

const VIB_PALETTE = [
{ max: 0.5, color: โ€˜#3cb44bโ€™, label: โ€˜< 0.5โ€™ },
{ max: 1.0, color: โ€˜#ffe119โ€™, label: โ€˜0.5-1.0โ€™ },
{ max: 2.0, color: โ€˜#f58231โ€™, label: โ€˜1.0-2.0โ€™ },
{ max: Infinity, color: โ€˜#e6194bโ€™, label: โ€˜> 2.0โ€™ }
];

function colorForSpeed(speed) {
for (const p of SPEED_PALETTE) {
if (speed <= p.max) return p.color;
}
return โ€˜#e6194bโ€™;
}

function colorForVibration(vib) {
for (const p of VIB_PALETTE) {
if (vib <= p.max) return p.color;
}
return โ€˜#e6194bโ€™;
}

function interpolateColor(hex1, hex2, t) {
const r1 = parseInt(hex1.slice(1, 3), 16);
const g1 = parseInt(hex1.slice(3, 5), 16);
const b1 = parseInt(hex1.slice(5, 7), 16);
const r2 = parseInt(hex2.slice(1, 3), 16);
const g2 = parseInt(hex2.slice(3, 5), 16);
const b2 = parseInt(hex2.slice(5, 7), 16);
const r = Math.round(r1 + (r2 - r1) * t).toString(16).padStart(2, โ€˜0โ€™);
const g = Math.round(g1 + (g2 - g1) * t).toString(16).padStart(2, โ€˜0โ€™);
const b = Math.round(b1 + (b2 - b1) * t).toString(16).padStart(2, โ€˜0โ€™);
return โ€˜#โ€™ + r + g + b;
}

export default class TrackWidget extends LitElement {
static styles = css :host { display: block; height: 100%; } :host > div { display: flex; flex-direction: column; height: 100%; padding: var(--c8y-root-component-padding, 16px); box-sizing: border-box; gap: 12px; } .header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; } .header-title { font-size: 16px; font-weight: 600; color: var(--text-color); margin: 0; } .header-sub { font-size: 12px; color: var(--text-muted); margin: 0; } .controls { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } .toggle-group { display: flex; border: 1px solid var(--c8y-root-component-border-color); border-radius: var(--btn-border-radius-base, 4px); overflow: hidden; } .toggle-btn { padding: 5px 14px; font-size: 12px; font-weight: 500; border: none; background: var(--body-background-color); color: var(--text-muted); cursor: pointer; transition: background 0.15s, color 0.15s; border-right: 1px solid var(--c8y-root-component-border-color); } .toggle-btn:last-child { border-right: none; } .toggle-btn.active { background: var(--c8y-brand-primary, #1776bf); color: #fff; } .map-wrapper { flex: 1; min-height: 300px; border: 1px solid var(--c8y-root-component-border-color); border-radius: var(--c8y-root-component-border-radius-base, 4px); overflow: hidden; position: relative; } #track-map { width: 100%; height: 100%; } .legend { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; font-size: 11px; color: var(--text-muted); padding: 4px 0; } .legend-title { font-weight: 600; color: var(--text-color); margin-right: 4px; } .legend-item { display: flex; align-items: center; gap: 4px; } .legend-swatch { width: 22px; height: 6px; border-radius: 3px; display: inline-block; } .stats-row { display: flex; gap: 12px; flex-wrap: wrap; } .stat-card { flex: 1; min-width: 100px; border: 1px solid var(--c8y-root-component-border-color); border-radius: var(--c8y-root-component-border-radius-base, 4px); padding: 10px 14px; background: var(--body-background-color); } .stat-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.4px; } .stat-value { font-size: 18px; font-weight: 600; color: var(--text-color); margin-top: 2px; } .stat-unit { font-size: 11px; color: var(--text-muted); margin-left: 2px; } .loading-overlay { position: absolute; inset: 0; background: rgba(255,255,255,0.82); display: flex; align-items: center; justify-content: center; z-index: 9999; } .no-data { text-align: center; padding: 40px 20px; color: var(--text-muted); font-size: 14px; } ;

static properties = {
c8yContext: { type: Object },
loading: { type: Boolean },
error: { type: String },
mode: { type: String },
points: { type: Array },
stats: { type: Object },
map: { type: Object },
layers: { type: Array }
};

constructor() {
super();
this.loading = false;
this.error = null;
this.mode = โ€˜speedโ€™;
this.points = ;
this.stats = null;
this.map = null;
this.layers = ;
}

async connectedCallback() {
super.connectedCallback();
await this.updateComplete;
if (this.c8yContext?.id) {
await this.loadData();
}
}

disconnectedCallback() {
super.disconnectedCallback();
if (this.map) {
this.map.remove();
this.map = null;
}
}

async updated(changedProps) {
if (changedProps.has(โ€˜c8yContextโ€™) && this.c8yContext?.id) {
await this.loadData();
}
if (changedProps.has(โ€˜modeโ€™) && this.points.length > 0) {
this.renderTrack();
}
}

async fetchAllPages(url, key, pageSize = 500, maxPages = 6) {
let results = ;
let page = 1;
while (page <= maxPages) {
const sep = url.includes(โ€˜?โ€™) ? โ€˜&โ€™ : โ€˜?โ€™;
const res = await fetch(${url}${sep}pageSize=${pageSize}&currentPage=${page});
const data = await res.json();
const items = data[key] || ;
results = results.concat(items);
if (items.length < pageSize) break;
page++;
}
return results;
}

async loadData() {
if (!this.c8yContext?.id) return;
this.loading = true;
this.error = null;
this.points = ;
this.stats = null;

try {
  const now = new Date();
  const from = new Date(now.getTime() - 24 * 60 * 60 * 1000);
  const dateFrom = from.toISOString();
  const dateTo = now.toISOString();
  const src = this.c8yContext.id;

  const [locationEvents, wagonMeasurements, speedMeasurements] = await Promise.all([
    this.fetchAllPages(
      `/event/events?type=c8y_LocationUpdate&source=${src}&revert=true&dateFrom=${dateFrom}&dateTo=${dateTo}`,
      'events'
    ),
    this.fetchAllPages(
      `/measurement/measurements?type=c8y_WagonMeasurements&source=${src}&revert=true&dateFrom=${dateFrom}&dateTo=${dateTo}`,
      'measurements'
    ),
    this.fetchAllPages(
      `/measurement/measurements?type=c8y_SpeedMeasurement&source=${src}&revert=true&dateFrom=${dateFrom}&dateTo=${dateTo}`,
      'measurements'
    )
  ]);

  const vibByTime = new Map();
  for (const m of wagonMeasurements) {
    const t = Math.round(new Date(m.time).getTime() / 1000);
    const val = m.c8y_Vibration?.V?.value;
    if (val != null) vibByTime.set(t, val);
  }

  const speedByTime = new Map();
  for (const m of speedMeasurements) {
    const t = Math.round(new Date(m.time).getTime() / 1000);
    const val = m.c8y_SpeedMeasurement?.speed?.value;
    if (val != null) speedByTime.set(t, val);
  }

  const pts = [];
  for (const ev of locationEvents) {
    const pos = ev.c8y_Position;
    if (!pos || pos.lat == null || pos.lng == null) continue;
    const t = Math.round(new Date(ev.time).getTime() / 1000);
    let speed = null, vibration = null;
    for (let d = 0; d <= 3; d++) {
      for (const delta of d === 0 ? [0] : [d, -d]) {
        const key = t + delta;
        if (speed == null && speedByTime.has(key)) speed = speedByTime.get(key);
        if (vibration == null && vibByTime.has(key)) vibration = vibByTime.get(key);
      }
      if (speed != null && vibration != null) break;
    }
    pts.push({
      lat: pos.lat,
      lng: pos.lng,
      time: new Date(ev.time),
      speed: speed,
      vibration: vibration
    });
  }

  pts.sort((a, b) => a.time - b.time);
  this.points = pts;

  if (pts.length > 0) {
    const speeds = pts.map(p => p.speed).filter(v => v != null);
    const vibs = pts.map(p => p.vibration).filter(v => v != null);
    this.stats = {
      count: pts.length,
      avgSpeed: speeds.length ? speeds.reduce((s, v) => s + v, 0) / speeds.length : null,
      maxSpeed: speeds.length ? Math.max(...speeds) : null,
      avgVib: vibs.length ? vibs.reduce((s, v) => s + v, 0) / vibs.length : null,
      maxVib: vibs.length ? Math.max(...vibs) : null,
      duration: (pts[pts.length - 1].time - pts[0].time) / 3600000
    };
  }

  await this.updateComplete;
  this.initMap();
} catch (e) {
  console.error(e);
  this.error = 'Failed to load track data: ' + e.message;
} finally {
  this.loading = false;
}

}

initMap() {
if (!window.L) { this.error = โ€˜Map library not availableโ€™; return; }
const container = this.shadowRoot.querySelector(โ€˜#track-mapโ€™);
if (!container) return;
if (this.map) { this.map.remove(); this.map = null; }

this.map = window.L.map(container, { zoomControl: true });
window.L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
  attribution: '&copy; OpenStreetMap contributors',
  maxZoom: 18
}).addTo(this.map);

setTimeout(() => {
  if (this.map) this.map.invalidateSize();
  this.renderTrack();
}, 120);

}

clearLayers() {
for (const l of this.layers) {
if (this.map && l) this.map.removeLayer(l);
}
this.layers = ;
}

renderTrack() {
if (!this.map || !window.L) return;
this.clearLayers();
const pts = this.points;
if (pts.length === 0) return;

const bounds = [];

for (let i = 0; i < pts.length - 1; i++) {
  const a = pts[i];
  const b = pts[i + 1];

  const colorA = this.mode === 'speed'
    ? colorForSpeed(a.speed ?? 0)
    : colorForVibration(a.vibration ?? 0);
  const colorB = this.mode === 'speed'
    ? colorForSpeed(b.speed ?? 0)
    : colorForVibration(b.vibration ?? 0);
  const midColor = interpolateColor(colorA, colorB, 0.5);

  const line = window.L.polyline(
    [[a.lat, a.lng], [b.lat, b.lng]],
    { color: midColor, weight: 5, opacity: 0.88, lineCap: 'round' }
  );

  const speedText = a.speed != null ? a.speed.toFixed(1) + ' km/h' : 'N/A';
  const vibText = a.vibration != null ? a.vibration.toFixed(3) + ' g' : 'N/A';
  line.bindTooltip(
    '<div style="font-size:12px;line-height:1.6">' +
    '<strong>' + a.time.toLocaleTimeString() + '</strong><br>' +
    'Speed: ' + speedText + '<br>' +
    'Vibration: ' + vibText +
    '</div>',
    { sticky: true }
  );

  line.addTo(this.map);
  this.layers.push(line);
  bounds.push([a.lat, a.lng]);
}
bounds.push([pts[pts.length - 1].lat, pts[pts.length - 1].lng]);

const startIcon = window.L.divIcon({
  html: '<div style="width:14px;height:14px;border-radius:50%;background:#3cb44b;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.4)"></div>',
  className: '', iconAnchor: [7, 7]
});
const startMarker = window.L.marker([pts[0].lat, pts[0].lng], { icon: startIcon })
  .bindPopup('<strong>Start</strong><br>' + pts[0].time.toLocaleString())
  .addTo(this.map);
this.layers.push(startMarker);

const last = pts[pts.length - 1];
const endIcon = window.L.divIcon({
  html: '<div style="width:14px;height:14px;border-radius:50%;background:#e6194b;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.4)"></div>',
  className: '', iconAnchor: [7, 7]
});
const endMarker = window.L.marker([last.lat, last.lng], { icon: endIcon })
  .bindPopup('<strong>End</strong><br>' + last.time.toLocaleString())
  .addTo(this.map);
this.layers.push(endMarker);

for (let i = 1; i < pts.length - 1; i += Math.max(1, Math.floor(pts.length / 120))) {
  const p = pts[i];
  const dotColor = this.mode === 'speed' ? colorForSpeed(p.speed ?? 0) : colorForVibration(p.vibration ?? 0);
  const dot = window.L.circleMarker([p.lat, p.lng], {
    radius: 4,
    fillColor: dotColor,
    color: '#fff',
    weight: 1.5,
    fillOpacity: 0.95
  });
  dot.bindPopup(
    '<div style="font-size:12px;min-width:160px;line-height:1.7">' +
    '<strong>' + p.time.toLocaleString() + '</strong><br>' +
    'Speed: ' + (p.speed != null ? p.speed.toFixed(1) + ' km/h' : 'N/A') + '<br>' +
    'Vibration: ' + (p.vibration != null ? p.vibration.toFixed(3) + ' g' : 'N/A') + '<br>' +
    'Lat: ' + p.lat.toFixed(6) + ', Lng: ' + p.lng.toFixed(6) +
    '</div>'
  );
  dot.addTo(this.map);
  this.layers.push(dot);
}

if (bounds.length > 1) {
  this.map.fitBounds(bounds, { padding: [30, 30] });
} else if (bounds.length === 1) {
  this.map.setView(bounds[0], 14);
}

}

setMode(m) {
this.mode = m;
}

renderLegend() {
const palette = this.mode === โ€˜speedโ€™ ? SPEED_PALETTE : VIB_PALETTE;
const unit = this.mode === โ€˜speedโ€™ ? โ€˜km/hโ€™ : โ€˜gโ€™;
return html <div class="legend"> <span class="legend-title">${this.mode === 'speed' ? 'Speed' : 'Vibration'}:</span> ${palette.map(p => html


${p.label} ${unit}

)} <span class="legend-item" style="margin-left:8px"> <span class="legend-swatch" style="background:#3cb44b;width:10px;height:10px;border-radius:50%;border:1.5px solid #fff"></span> Start </span> <span class="legend-item"> <span class="legend-swatch" style="background:#e6194b;width:10px;height:10px;border-radius:50%;border:1.5px solid #fff"></span> End </span> </div> ;
}

renderStats() {
if (!this.stats) return html; const s = this.stats; return html` <div class="stats-row"> <div class="stat-card"> <div class="stat-label">GPS Points</div> <div class="stat-value">${s.count}</div> </div> <div class="stat-card"> <div class="stat-label">Duration</div> <div class="stat-value">${s.duration.toFixed(1)}<span class="stat-unit">h</span></div> </div> ${s.avgSpeed != null ? html` <div class="stat-card"> <div class="stat-label">Avg Speed</div> <div class="stat-value">${s.avgSpeed.toFixed(1)}<span class="stat-unit">km/h</span></div> </div> <div class="stat-card"> <div class="stat-label">Max Speed</div> <div class="stat-value">${s.maxSpeed.toFixed(1)}<span class="stat-unit">km/h</span></div> </div>` : html}
${s.avgVib != null ? html <div class="stat-card"> <div class="stat-label">Avg Vibration</div> <div class="stat-value">${s.avgVib.toFixed(3)}<span class="stat-unit">g</span></div> </div> <div class="stat-card"> <div class="stat-label">Max Vibration</div> <div class="stat-value">${s.maxVib.toFixed(3)}<span class="stat-unit">g</span></div> </div> : html``}

`;
}

render() {
const deviceName = this.c8yContext?.name || โ€˜No device selectedโ€™;

if (!this.c8yContext?.id) {
  return html`
    <style>${styleImports}</style>
    <div>
      <div class="no-data">
        <p style="font-size:24px;margin-bottom:8px">No device selected</p>
        <p>Please assign a device to this widget to see the 24-hour track.</p>
      </div>
    </div>
  `;
}

return html`
  <style>${styleImports}</style>
  <div>
    <div class="header">
      <div>
        <p class="header-title">${deviceName} - 24h Track</p>
        <p class="header-sub">Last 24 hours of GPS positions with sensor data</p>
      </div>
      <div class="controls">
        <div class="toggle-group" role="group" aria-label="Color mode">
          <button class="toggle-btn ${this.mode === 'speed' ? 'active' : ''}"
            @click="${() => this.setMode('speed')}" type="button">Speed</button>
          <button class="toggle-btn ${this.mode === 'vibration' ? 'active' : ''}"
            @click="${() => this.setMode('vibration')}" type="button">Vibration</button>
        </div>
        <button class="btn btn-default btn-sm" type="button"
          @click="${() => this.loadData()}"
          ?disabled="${this.loading}">
          ${this.loading ? 'Loading...' : 'Refresh'}
        </button>
      </div>
    </div>

    ${this.error ? html`
      <div class="alert alert-danger" role="alert">
        <strong>Error:</strong> ${this.error}
      </div>
    ` : html``}

    <div class="map-wrapper">
      <div id="track-map"></div>
      ${this.loading ? html`
        <div class="loading-overlay">
          <div class="spinner">
            <div class="rect1"></div><div class="rect2"></div>
            <div class="rect3"></div><div class="rect4"></div><div class="rect5"></div>
          </div>
        </div>
      ` : html``}
    </div>

    ${!this.loading && this.points.length === 0 && !this.error ? html`
      <div class="no-data">
        <p>No location data found for the last 24 hours.</p>
      </div>
    ` : html``}

    ${this.renderLegend()}
    ${this.renderStats()}
  </div>
`;

}
}

4 Likes

I was able to add a Generate GPX File using a custom AI HTML widget. This enables users to export GPS coordinates of selected devices (for example, low-battery sensors) into a GPX file. The file can then be used for routing and navigation in tools like Google Maps, Waze, or Komoot.

1 Like