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>
    `;
  }
}

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`
          <span class="legend-item">
            <span class="legend-swatch" style="background:${p.color}"></span>
            <span>${p.label} ${unit}</span>
          </span>
        `)}
        <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``}
      </div>
    `;
  }

  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>
    `;
  }
}

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.

@Rahul_Talreja even added a little bit more functionality to support the field personal Automated GPX Route Generation for IoT Service Teams

Hi Guys

created with the AI and the HTML-Widget some nice functionality to support developers and field staff, to see an and check the upcoming new DTM functionality.

Prompt: Create a widget which shows me in a filtered and sorted way within a list, which asset as which device assigned and additionally show me the asset type and the device typ

To get a clearer view to assign and created c8y_linkedSeries from devices to assets I created a new widget with some additional functionality

Prompt: Create a widget with a 3 column table view where I can create c8y_linkedSeries between devices and assets.
Hint: took more than one prompt to finalize it

additionally I added the possibility to use the data point library

once you have created all your linkages you possibly want to know … which device has which linkage and to which asset it is assigned..here is the generated HTML widget:

Another one …

Use Case: If you accidentally or on purpose delete a device and you forgot to remove the linked series from an asset, you need something to search for it.

Prompt: Create a widget which search assets where the status of the c8y_linkedSeries is broken:

If you have to deal with device movement within assets and you need to move one or multiple devices to a new asset:

And especially for developers i created a widget to show the JSON-View of a device or asset