From 89323f3cc61b6a2147c9e1646487717b52359f9a Mon Sep 17 00:00:00 2001 From: wyattb Date: Thu, 12 Mar 2026 05:53:27 -0400 Subject: [PATCH 1/4] #174 Add MQTT mobile view with tab switching on landing page Add a scrollable MQTT values list for mobile users and a tab bar to switch between the existing Dashboard view and the new MQTT Values view. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../landing-page-mobile.component.css | 38 ++++++ .../landing-page-mobile.component.html | 57 +++++++-- .../landing-page-mobile.component.ts | 18 ++- .../mqtt-mobile-view.component.css | 103 ++++++++++++++++ .../mqtt-mobile-view.component.html | 36 ++++++ .../mqtt-mobile-view.component.ts | 110 ++++++++++++++++++ 6 files changed, 346 insertions(+), 16 deletions(-) create mode 100644 angular-client/src/pages/landing-page/landing-page-mobile/mqtt-mobile-view/mqtt-mobile-view.component.css create mode 100644 angular-client/src/pages/landing-page/landing-page-mobile/mqtt-mobile-view/mqtt-mobile-view.component.html create mode 100644 angular-client/src/pages/landing-page/landing-page-mobile/mqtt-mobile-view/mqtt-mobile-view.component.ts diff --git a/angular-client/src/pages/landing-page/landing-page-mobile/landing-page-mobile.component.css b/angular-client/src/pages/landing-page/landing-page-mobile/landing-page-mobile.component.css index e69de29b..6b8b0090 100644 --- a/angular-client/src/pages/landing-page/landing-page-mobile/landing-page-mobile.component.css +++ b/angular-client/src/pages/landing-page/landing-page-mobile/landing-page-mobile.component.css @@ -0,0 +1,38 @@ +.tab-bar { + display: flex; + gap: 0; + margin: 8px 0 12px 0; + background-color: var(--color-background-info, #2c2c2c); + border-radius: 8px; + padding: 3px; +} + +.tab-button { + flex: 1; + padding: 10px 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--color-text-secondary, #797a7a); + font-size: 14px; + font-weight: 500; + font-family: inherit; + cursor: pointer; + transition: background-color 0.2s, color 0.2s; +} + +.tab-button.active { + background-color: #ef4343; + color: #fff; +} + +.tab-button:focus-visible { + outline: 2px solid #ef4343; + outline-offset: 2px; +} + +.mqtt-panel { + width: 100%; + min-height: 0; + flex: 1; +} diff --git a/angular-client/src/pages/landing-page/landing-page-mobile/landing-page-mobile.component.html b/angular-client/src/pages/landing-page/landing-page-mobile/landing-page-mobile.component.html index 4738273d..6c8ee783 100644 --- a/angular-client/src/pages/landing-page/landing-page-mobile/landing-page-mobile.component.html +++ b/angular-client/src/pages/landing-page/landing-page-mobile/landing-page-mobile.component.html @@ -1,17 +1,50 @@ - - - - - +
+ + +
- - +@switch (activeTab()) { + @case ('dashboard') { + + + + - + - - - - + + + + + + + + +
+ } + @case ('mqtt') { +
+ +
+ } +} diff --git a/angular-client/src/pages/landing-page/landing-page-mobile/landing-page-mobile.component.ts b/angular-client/src/pages/landing-page/landing-page-mobile/landing-page-mobile.component.ts index 2e2fa133..ae2e0459 100644 --- a/angular-client/src/pages/landing-page/landing-page-mobile/landing-page-mobile.component.ts +++ b/angular-client/src/pages/landing-page/landing-page-mobile/landing-page-mobile.component.ts @@ -1,4 +1,4 @@ -import { Component, Input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core'; import { DriverComponent } from '../../../components/driver-component/driver-component'; import { AccelerationGraphsComponent } from '../../../components/acceleration-graphs/acceleration-graphs.component'; @@ -11,12 +11,15 @@ import SpeedOverTimeDisplayComponent from 'src/components/speed-over-time-displa import TypographyComponent from 'src/components/typography/typography.component'; import VStackComponent from 'src/components/vstack/vstack.component'; import SidebarToggleComponent from 'src/components/sidebar-toggle/sidebar-toggle.component'; +import MqttMobileViewComponent from './mqtt-mobile-view/mqtt-mobile-view.component'; + +export type MobileTab = 'dashboard' | 'mqtt'; @Component({ selector: 'landing-page-mobile', templateUrl: './landing-page-mobile.component.html', styleUrls: ['./landing-page-mobile.component.css'], - standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ TypographyComponent, VStackComponent, @@ -28,9 +31,16 @@ import SidebarToggleComponent from 'src/components/sidebar-toggle/sidebar-toggle DatePipe, RasberryPiComponent, MotorInfoComponent, - AccelerationOverTimeDisplayComponent + AccelerationOverTimeDisplayComponent, + MqttMobileViewComponent ] }) export default class LandingPageMobileComponent { - @Input() time!: Date; + time = input.required(); + + activeTab = signal('dashboard'); + + setTab(tab: MobileTab): void { + this.activeTab.set(tab); + } } diff --git a/angular-client/src/pages/landing-page/landing-page-mobile/mqtt-mobile-view/mqtt-mobile-view.component.css b/angular-client/src/pages/landing-page/landing-page-mobile/mqtt-mobile-view/mqtt-mobile-view.component.css new file mode 100644 index 00000000..bf11155c --- /dev/null +++ b/angular-client/src/pages/landing-page/landing-page-mobile/mqtt-mobile-view/mqtt-mobile-view.component.css @@ -0,0 +1,103 @@ +.mqtt-mobile-container { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; +} + +.search-bar { + position: sticky; + top: 0; + z-index: 1; + padding: 8px 0; + background-color: var(--color-background-page, #101010); +} + +.search-input { + width: 100%; + padding: 10px 14px; + border: 1px solid var(--color-divider, #414141); + border-radius: 8px; + background-color: var(--color-background-info, #2c2c2c); + color: var(--color-text-primary, #efefef); + font-size: 14px; + font-family: inherit; + outline: none; + box-sizing: border-box; +} + +.search-input::placeholder { + color: var(--color-text-secondary, #797a7a); +} + +.search-input:focus { + border-color: #ef4343; +} + +.loading-container { + display: flex; + justify-content: center; + align-items: center; + padding: 40px 0; +} + +.values-list { + display: flex; + flex-direction: column; + gap: 2px; + padding-bottom: 20px; +} + +.value-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + background-color: var(--color-background-info, #2c2c2c); + border-radius: 6px; + min-height: 44px; + box-sizing: border-box; +} + +.value-row:active { + background-color: #3f3f3f; +} + +.topic-info { + flex: 1; + min-width: 0; + padding-right: 12px; +} + +.topic-name { + color: var(--color-text-primary, #efefef); + font-size: 13px; + line-height: 1.3; + word-break: break-word; +} + +.value-info { + display: flex; + align-items: baseline; + gap: 4px; + flex-shrink: 0; +} + +.topic-value { + color: #ef4343; + font-size: 15px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.topic-unit { + color: var(--color-text-secondary, #797a7a); + font-size: 12px; +} + +.empty-state { + display: flex; + justify-content: center; + align-items: center; + padding: 40px 0; +} diff --git a/angular-client/src/pages/landing-page/landing-page-mobile/mqtt-mobile-view/mqtt-mobile-view.component.html b/angular-client/src/pages/landing-page/landing-page-mobile/mqtt-mobile-view/mqtt-mobile-view.component.html new file mode 100644 index 00000000..f3bf4b08 --- /dev/null +++ b/angular-client/src/pages/landing-page/landing-page-mobile/mqtt-mobile-view/mqtt-mobile-view.component.html @@ -0,0 +1,36 @@ +
+ + + @if (isLoading()) { +
+ +
+ } @else { +
+ @for (entry of filteredEntries(); track entry.name) { +
+
+ {{ entry.displayName }} +
+
+ {{ entry.value }} + {{ entry.unit }} +
+
+ } @empty { +
+ +
+ } +
+ } +
diff --git a/angular-client/src/pages/landing-page/landing-page-mobile/mqtt-mobile-view/mqtt-mobile-view.component.ts b/angular-client/src/pages/landing-page/landing-page-mobile/mqtt-mobile-view/mqtt-mobile-view.component.ts new file mode 100644 index 00000000..8f75ca69 --- /dev/null +++ b/angular-client/src/pages/landing-page/landing-page-mobile/mqtt-mobile-view/mqtt-mobile-view.component.ts @@ -0,0 +1,110 @@ +import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core'; +import { Subscription } from 'rxjs'; +import { getAllDatatypes } from 'src/api/datatype.api'; +import APIService from 'src/services/api.service'; +import Storage from 'src/services/storage.service'; +import { decimalPipe } from 'src/utils/pipes.utils'; +import { DataType } from 'src/utils/types.utils'; +import TypographyComponent from 'src/components/typography/typography.component'; + +interface MqttValueEntry { + name: string; + displayName: string; + value: string; + unit: string; +} + +@Component({ + selector: 'mqtt-mobile-view', + templateUrl: './mqtt-mobile-view.component.html', + styleUrls: ['./mqtt-mobile-view.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TypographyComponent] +}) +export default class MqttMobileViewComponent implements OnInit, OnDestroy { + private storage = inject(Storage); + private serverService = inject(APIService); + + private subscriptions: Subscription[] = []; + + searchQuery = signal(''); + mqttEntries = signal([]); + isLoading = signal(true); + + filteredEntries = computed(() => { + const query = this.searchQuery().toLowerCase(); + const entries = this.mqttEntries(); + if (!query) { + return entries; + } + return entries.filter( + (entry) => entry.name.toLowerCase().includes(query) || entry.displayName.toLowerCase().includes(query) + ); + }); + + ngOnInit(): void { + this.queryDataTypes(); + } + + ngOnDestroy(): void { + this.subscriptions.forEach((sub) => sub.unsubscribe()); + } + + onSearchInput(event: Event): void { + const target = event.target as HTMLInputElement; + this.searchQuery.set(target.value); + } + + private queryDataTypes(): void { + const dataTypesQuery = this.serverService.query(getAllDatatypes); + + this.subscriptions.push( + dataTypesQuery.isLoading.subscribe((loading: boolean) => { + this.isLoading.set(loading); + }) + ); + + this.subscriptions.push( + dataTypesQuery.data.subscribe((dataTypes) => { + if (dataTypes) { + this.setupLiveValues(dataTypes); + } + }) + ); + } + + private setupLiveValues(dataTypes: DataType[]): void { + const entries: MqttValueEntry[] = dataTypes.map((dt) => ({ + name: dt.name, + displayName: this.formatTopicName(dt.name), + value: '--', + unit: dt.unit + })); + + this.mqttEntries.set(entries); + + dataTypes.forEach((dt) => { + this.subscriptions.push( + this.storage.get(dt.name).subscribe((dataValue) => { + this.mqttEntries.update((current) => + current.map((entry) => { + if (entry.name === dt.name) { + const numericValue = decimalPipe(dataValue.values[0], 3).toFixed(3); + return { ...entry, value: numericValue, unit: dataValue.unit }; + } + return entry; + }) + ); + }) + ); + }); + } + + private formatTopicName(name: string): string { + const parts = name.split('/'); + if (parts.length <= 1) { + return name; + } + return parts.join(' / '); + } +} From 22f782e86993c8bc195d435812d8fe1dd75f951e Mon Sep 17 00:00:00 2001 From: wyattb Date: Sun, 29 Mar 2026 12:54:23 -0400 Subject: [PATCH 2/4] #174 - PrimeNG sidebar, live value strip, responsive graph page --- .gitignore | 7 +- .../argos-button/argos-button.component.css | 8 + .../general-buttons.component.css | 28 +++ .../general-buttons.component.html | 4 +- .../general-buttons.component.ts | 3 +- .../pages/graph-page/graph-page.component.css | 95 +++++++++- .../graph-page/graph-page.component.html | 97 +++++----- .../pages/graph-page/graph-page.component.ts | 8 +- .../graph-sidebar-mobile.component.css | 59 ++++--- .../graph-sidebar-mobile.component.html | 53 ++++-- .../graph-sidebar-mobile.component.ts | 166 +++++++++++------- .../live-value-strip.component.css | 50 ++++++ .../live-value-strip.component.html | 10 ++ .../live-value-strip.component.ts | 66 +++++++ .../landing-page-mobile.component.css | 38 ---- .../landing-page-mobile.component.html | 56 ++---- .../landing-page-mobile.component.ts | 14 +- .../mqtt-mobile-view.component.css | 103 ----------- .../mqtt-mobile-view.component.html | 36 ---- .../mqtt-mobile-view.component.ts | 110 ------------ 20 files changed, 511 insertions(+), 500 deletions(-) create mode 100644 angular-client/src/pages/graph-page/live-value-strip/live-value-strip.component.css create mode 100644 angular-client/src/pages/graph-page/live-value-strip/live-value-strip.component.html create mode 100644 angular-client/src/pages/graph-page/live-value-strip/live-value-strip.component.ts delete mode 100644 angular-client/src/pages/landing-page/landing-page-mobile/mqtt-mobile-view/mqtt-mobile-view.component.css delete mode 100644 angular-client/src/pages/landing-page/landing-page-mobile/mqtt-mobile-view/mqtt-mobile-view.component.html delete mode 100644 angular-client/src/pages/landing-page/landing-page-mobile/mqtt-mobile-view/mqtt-mobile-view.component.ts diff --git a/.gitignore b/.gitignore index b767049c..da097022 100644 --- a/.gitignore +++ b/.gitignore @@ -104,4 +104,9 @@ iOSInjectionProject/ # user compose override compose.override.yml **/.DS_Store -**/.vs \ No newline at end of file +**/.vs + +# AI tooling +.playwright-mcp/ +.wolf/ +.claude/ \ No newline at end of file diff --git a/angular-client/src/components/argos-button/argos-button.component.css b/angular-client/src/components/argos-button/argos-button.component.css index 2975c4cb..f56ec9a9 100644 --- a/angular-client/src/components/argos-button/argos-button.component.css +++ b/angular-client/src/components/argos-button/argos-button.component.css @@ -23,3 +23,11 @@ transition-duration: 0.2s; background-color: #f04346b2; } + +@media (max-width: 768px) { + .btn { + padding: 6px 8px; + font-size: 13px; + min-height: 44px; + } +} diff --git a/angular-client/src/pages/graph-page/graph-caption/general-buttons/general-buttons.component.css b/angular-client/src/pages/graph-page/graph-caption/general-buttons/general-buttons.component.css index dfb3ee6b..22ccd7a3 100644 --- a/angular-client/src/pages/graph-page/graph-caption/general-buttons/general-buttons.component.css +++ b/angular-client/src/pages/graph-page/graph-caption/general-buttons/general-buttons.component.css @@ -7,3 +7,31 @@ height: 100%; margin: 0px 5px 0px 15px; } + +.general-buttons-row { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +@media (max-width: 768px) { + :host { + margin: 0; + width: 100%; + } + + .general-buttons-row { + gap: 6px; + width: 100%; + } + + :host ::ng-deep select-dropdown { + min-width: 130px; + } + + :host ::ng-deep select-dropdown .p-select { + min-width: 130px; + } +} diff --git a/angular-client/src/pages/graph-page/graph-caption/general-buttons/general-buttons.component.html b/angular-client/src/pages/graph-page/graph-caption/general-buttons/general-buttons.component.html index 75707249..8818f7e3 100644 --- a/angular-client/src/pages/graph-page/graph-caption/general-buttons/general-buttons.component.html +++ b/angular-client/src/pages/graph-page/graph-caption/general-buttons/general-buttons.component.html @@ -1,4 +1,4 @@ - +
@if (historicalOn()) { @@ -13,4 +13,4 @@ [placeholder]="selectorConfig().placeholder" [defaultValue]="selectorConfig().defaultValue" /> - +
diff --git a/angular-client/src/pages/graph-page/graph-caption/general-buttons/general-buttons.component.ts b/angular-client/src/pages/graph-page/graph-caption/general-buttons/general-buttons.component.ts index 304ac5b6..eee4236e 100644 --- a/angular-client/src/pages/graph-page/graph-caption/general-buttons/general-buttons.component.ts +++ b/angular-client/src/pages/graph-page/graph-caption/general-buttons/general-buttons.component.ts @@ -5,14 +5,13 @@ import { Run } from 'src/utils/types.utils'; import { RunSelectorComponent } from '../run-selector/run-selector.component'; import { ButtonComponent } from '../../../../components/argos-button/argos-button.component'; import { SelectDropdownComponent } from '../../../../components/select-dropdown/select-dropdown.component'; -import HStackComponent from 'src/components/hstack/hstack.component'; @Component({ selector: 'general-buttons', templateUrl: './general-buttons.component.html', styleUrl: './general-buttons.component.css', standalone: true, - imports: [RunSelectorComponent, ButtonComponent, SelectDropdownComponent, HStackComponent] + imports: [RunSelectorComponent, ButtonComponent, SelectDropdownComponent] }) export class GeneralButtonsComponent { historicalOn = input(false); diff --git a/angular-client/src/pages/graph-page/graph-page.component.css b/angular-client/src/pages/graph-page/graph-page.component.css index 94c4d4fe..dec79f0b 100644 --- a/angular-client/src/pages/graph-page/graph-page.component.css +++ b/angular-client/src/pages/graph-page/graph-page.component.css @@ -7,6 +7,45 @@ flex-flow: column; } +/* Toolbar: replaces the old inline-styled hstack */ +.toolbar { + display: flex; + align-items: start; + justify-content: space-between; + align-self: flex-start; + width: 100%; +} + +.toolbar-left { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; +} + +.toolbar-group { + display: flex; + align-items: center; + gap: 5px; +} + +.config-group { + margin-left: 15px; +} + +.input-label { + color: #fff; + font-size: 12px; + white-space: nowrap; +} + +.toolbar-header { + margin-top: 5px; + margin-bottom: 0; + padding-right: 15px; + flex-shrink: 0; +} + .graph-header { flex: 0 1 auto; } @@ -35,6 +74,10 @@ display: none; } +.mobile-value-strip { + display: none; +} + .graph { flex: 1 1 auto; } @@ -63,7 +106,36 @@ width: 100% !important; } +/* ======================== Mobile ======================== */ @media (max-width: 768px) { + .toolbar { + flex-direction: column; + align-items: stretch; + gap: 6px; + padding: 4px 8px; + } + + .toolbar-left { + flex-direction: column; + align-items: stretch; + gap: 6px; + } + + .toolbar-group { + flex-wrap: wrap; + gap: 6px; + } + + .config-group { + margin-left: 0; + } + + .toolbar-header { + padding-right: 0; + text-align: center; + margin-top: 2px; + } + .content-div { flex-flow: column; } @@ -76,11 +148,32 @@ display: block; } + .mobile-value-strip { + display: block; + } + .right-container { - flex: 1 1 auto; + height: 50vh; + min-height: 250px; + padding-right: 8px; + padding-left: 8px; } .graph-caption { flex: 0 0 auto; } + + ::ng-deep .time-range-input { + width: 70px !important; + } + + ::ng-deep .y-axis-input { + width: 55px !important; + } + + ::ng-deep .p-select-label, + ::ng-deep .p-select-label.p-placeholder { + width: auto !important; + min-width: 100px; + } } diff --git a/angular-client/src/pages/graph-page/graph-page.component.html b/angular-client/src/pages/graph-page/graph-page.component.html index 126fa1b3..5d3afe41 100644 --- a/angular-client/src/pages/graph-page/graph-page.component.html +++ b/angular-client/src/pages/graph-page/graph-page.component.html @@ -9,36 +9,37 @@ } @else {
- - - - - @if (realTime === true) { +
+
+
- } + + @if (realTime === true) { + + } +
-
- @if (this.realTime === true) { + @if (this.realTime === true) { +
- - + - } +
+ } - +
+ - +
- @if (onFaultPage) { - - } @else { - - } - +
+ @if (onFaultPage) { + + } @else { + + } +
+
- +
+ + @if (selectedDataTypes.length > 0) { + + }
@if (this.showSideBar) { - }
@@ -112,12 +115,6 @@ [graphConfig]="this.graphConfig" />
-
diff --git a/angular-client/src/pages/graph-page/graph-page.component.ts b/angular-client/src/pages/graph-page/graph-page.component.ts index 205b319f..223c7770 100644 --- a/angular-client/src/pages/graph-page/graph-page.component.ts +++ b/angular-client/src/pages/graph-page/graph-page.component.ts @@ -1,4 +1,4 @@ -import { Component, OnDestroy, OnInit, inject } from '@angular/core'; +import { ChangeDetectorRef, Component, OnDestroy, OnInit, inject } from '@angular/core'; import { ActivatedRoute, ParamMap, Router } from '@angular/router'; import { MessageService } from 'primeng/api'; import { BehaviorSubject, Subscription } from 'rxjs'; @@ -17,8 +17,8 @@ import { ButtonComponent } from '../../components/argos-button/argos-button.comp import { FaultButtonsComponent } from './graph-caption/fault-buttons/fault-buttons.component'; import { GeneralButtonsComponent } from './graph-caption/general-buttons/general-buttons.component'; import GraphSidebarComponent from './graph-sidebar/graph-sidebar.component'; -import HStackComponent from 'src/components/hstack/hstack.component'; import CustomGraphComponent from './graph/graph.component'; +import LiveValueStripComponent from './live-value-strip/live-value-strip.component'; import LoadingPageComponent from 'src/components/loading-page/loading-page.component'; import ErrorPageComponent from 'src/components/error-page/error-page.component'; import TypographyComponent from '../../components/typography/typography.component'; @@ -37,8 +37,8 @@ import { FormsModule } from '@angular/forms'; FaultButtonsComponent, GeneralButtonsComponent, GraphSidebarComponent, - HStackComponent, CustomGraphComponent, + LiveValueStripComponent, TypographyComponent, InputNumberModule, FormsModule @@ -52,6 +52,7 @@ export default class GraphPageComponent implements OnInit, OnDestroy { private topicSelectionService = inject(TopicSelectionService); private router = inject(Router); // for fault page navigation private route = inject(ActivatedRoute); + private cdr = inject(ChangeDetectorRef); // keep track of the subscriptions, that way we cancel all subs anywhere anytime subscriptions: Subscription[] = []; @@ -350,6 +351,7 @@ export default class GraphPageComponent implements OnInit, OnDestroy { this.persistentSubscriptions.push( dataTypesQueryResponse.isLoading.subscribe((isLoading: boolean) => { this.dataTypesIsLoading = isLoading; + this.cdr.detectChanges(); }) ); this.persistentSubscriptions.push( diff --git a/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-mobile/graph-sidebar-mobile.component.css b/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-mobile/graph-sidebar-mobile.component.css index b80f7e7c..9d760e0e 100644 --- a/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-mobile/graph-sidebar-mobile.component.css +++ b/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-mobile/graph-sidebar-mobile.component.css @@ -1,32 +1,47 @@ -.popup-tab { - height: fit-content; - bottom: 0px; - background-color: #1b1b1b; +.toggle-btn { position: fixed; - padding: 8px; - overflow: scroll; - max-width: 95%; - border-top-right-radius: 8px; - margin: 0px 0px 0px 8px; + bottom: calc(16px + env(safe-area-inset-bottom, 0px)); + right: calc(16px + env(safe-area-inset-right, 0px)); + z-index: 5; + padding: 12px 20px; + border: none; + border-radius: 8px; + background-color: #ef4343; + color: #fff; + font-size: 14px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); + min-height: 44px; } -.container { +.toggle-btn:active { + background-color: #d63535; +} + +.sidebar-content { display: flex; - flex-flow: row; - justify-content: flex-start; - width: fit-content; - align-items: center; - gap: 16px; + flex-direction: column; + gap: 8px; + height: 100%; } -.subcontainer { +.tree-node-container { display: flex; - flex-flow: row; - width: fit-content; - justify-content: flex-start; + align-items: center; + justify-content: space-between; + width: 100%; + min-height: 44px; +} + +:host { + --p-tree-background: transparent !important; + --p-tree-node-hover-color: #ef4343; + --p-tree-node-selected-background: #ef434355; + --p-tree-node-focus-ring-color: #ef4343; } -:host ::ng-deep sidebar-card .card { - height: 50px; - width: 100px; +::ng-deep .p-tree-node-label { + width: 100%; } diff --git a/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-mobile/graph-sidebar-mobile.component.html b/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-mobile/graph-sidebar-mobile.component.html index 88005e66..a362993b 100644 --- a/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-mobile/graph-sidebar-mobile.component.html +++ b/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-mobile/graph-sidebar-mobile.component.html @@ -1,16 +1,39 @@ -
- + @if (selectedDataTypes.length > 0) { diff --git a/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-mobile/graph-sidebar-mobile.component.html b/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-mobile/graph-sidebar-mobile.component.html index a362993b..7c26ed8c 100644 --- a/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-mobile/graph-sidebar-mobile.component.html +++ b/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-mobile/graph-sidebar-mobile.component.html @@ -1,6 +1,4 @@ - + diff --git a/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-mobile/graph-sidebar-mobile.component.ts b/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-mobile/graph-sidebar-mobile.component.ts index 966cbe6e..29eb3022 100644 --- a/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-mobile/graph-sidebar-mobile.component.ts +++ b/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-mobile/graph-sidebar-mobile.component.ts @@ -117,5 +117,4 @@ export default class GraphSidebarMobileComponent implements OnInit, OnDestroy { this.topicSelectionService.removeDataType(dt); } } - } From ada30f4f12abf98c09e2400271ab59e061c61a22 Mon Sep 17 00:00:00 2001 From: wyattb Date: Sun, 29 Mar 2026 13:08:49 -0400 Subject: [PATCH 4/4] #174 - extract shared tree utils, fix desktop sidebar bugs --- .gitignore | 2 +- .../graph-sidebar-desktop.component.html | 17 +-- .../graph-sidebar-desktop.component.ts | 124 +++--------------- .../graph-sidebar-mobile.component.ts | 61 ++------- .../landing-page-mobile.component.css | 0 .../landing-page-mobile.component.ts | 1 - angular-client/src/utils/tree.utils.ts | 71 ++++++++++ 7 files changed, 105 insertions(+), 171 deletions(-) delete mode 100644 angular-client/src/pages/landing-page/landing-page-mobile/landing-page-mobile.component.css create mode 100644 angular-client/src/utils/tree.utils.ts diff --git a/.gitignore b/.gitignore index da097022..2b4e95f5 100644 --- a/.gitignore +++ b/.gitignore @@ -109,4 +109,4 @@ compose.override.yml # AI tooling .playwright-mcp/ .wolf/ -.claude/ \ No newline at end of file +.claude/ diff --git a/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-desktop/graph-sidebar-desktop.component.html b/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-desktop/graph-sidebar-desktop.component.html index a147985a..9da9b8a4 100644 --- a/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-desktop/graph-sidebar-desktop.component.html +++ b/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-desktop/graph-sidebar-desktop.component.html @@ -9,7 +9,7 @@ styleClass="w-full md:w-[30rem]" (onNodeSelect)="nodeSelect($event)" (onNodeUnselect)="onNodeUnselect($event)" - [(selection)]="this.selectedNodes" + [(selection)]="selectedNodes" [filter]="true" filterPlaceholder="Select Data Type" styleClass="w-full" @@ -19,16 +19,11 @@ selectionMode="multiple" > -
-
- - @if (node.children.length === 0) { - - } -
+
+ + @if (node.children?.length === 0) { + + }
diff --git a/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-desktop/graph-sidebar-desktop.component.ts b/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-desktop/graph-sidebar-desktop.component.ts index b4821d60..b1610788 100644 --- a/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-desktop/graph-sidebar-desktop.component.ts +++ b/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-desktop/graph-sidebar-desktop.component.ts @@ -1,153 +1,67 @@ +import { AsyncPipe } from '@angular/common'; import { Component, OnInit, OnDestroy, input, inject } from '@angular/core'; -import { DataType, Node } from 'src/utils/types.utils'; -import { FormControl, FormGroup } from '@angular/forms'; -import { BehaviorSubject, debounceTime, Subscription } from 'rxjs'; +import { Subscription } from 'rxjs'; import { TreeNode, PrimeTemplate } from 'primeng/api'; -import Storage from 'src/services/storage.service'; -import { decimalPipe } from 'src/utils/pipes.utils'; import { TreeNodeSelectEvent, TreeNodeUnSelectEvent, Tree } from 'primeng/tree'; -import { dataTypeNamePipe, dataTypesToNodes } from 'src/utils/dataTypes.utils'; +import Storage from 'src/services/storage.service'; +import { dataTypesToNodes } from 'src/utils/dataTypes.utils'; +import { mapNodesToTreeNodes, findSelectedTreeNodes, TreeNodeData } from 'src/utils/tree.utils'; +import { DataType } from 'src/utils/types.utils'; +import { TopicSelectionService } from 'src/services/topic-selection.service'; import { ButtonComponent } from '../../../../components/argos-button/argos-button.component'; import TypographyComponent from 'src/components/typography/typography.component'; -import { TopicSelectionService } from 'src/services/topic-selection.service'; -/** - * Sidebar component that displays the nodes and their data types. - * @param nodes The nodes to display. - * Has animations for when a node is selected to collapse and expand the associated datatypes - * - */ @Component({ selector: 'graph-sidebar-desktop', templateUrl: './graph-sidebar-desktop.component.html', styleUrls: ['./graph-sidebar-desktop.component.css'], standalone: true, - imports: [ButtonComponent, Tree, PrimeTemplate, TypographyComponent] + imports: [AsyncPipe, ButtonComponent, Tree, PrimeTemplate, TypographyComponent] }) export default class GraphSidebarDesktopComponent implements OnInit, OnDestroy { private topicSelectionService = inject(TopicSelectionService); private storage = inject(Storage); - dataTypes = input([]); - nodes: Node[] = []; - - filterForm: FormGroup = new FormGroup({ - searchFilter: new FormControl('') - }); - filterFormSubsription!: Subscription; - searchFilter: string = ''; - treeNodes: TreeNode[] = []; - selectedNodes?: TreeNode[]; // still needed for p-tree binding - treeInitialized = false; + dataTypes = input([]); + treeNodes: TreeNode[] = []; + selectedNodes?: TreeNode[]; - // Local list of selected DataTypes - private selectedDataTypesList: DataType[] = []; + private storageSubscriptions: Subscription[] = []; - /** - * Initializes the nodes with the visibility toggle. - */ ngOnInit(): void { - this.nodes = dataTypesToNodes(this.dataTypes()); - - // Callback to update search regex (debounced at 300 ms) - this.filterFormSubsription = this.filterForm.valueChanges.pipe(debounceTime(300)).subscribe((changes) => { - this.searchFilter = changes.searchFilter; - }); - - const mapToTreeNode = (node: Node): TreeNode => { - const displayValue = new BehaviorSubject('N/A'); - this.storage.get(node.topicName.slice(0, -1)).subscribe((value) => { - displayValue.next(decimalPipe(value.values[0], 3).toFixed(3) + value.unit); - }); - return { - label: node.name, - data: { ...node, displayValue }, - key: node.topicName, - children: node.nodes.value.map(mapToTreeNode), - selectable: node.nodes.value.length === 0 - }; - }; - - this.treeNodes = this.nodes.map(mapToTreeNode); - // Helper function to find selected nodes in the tree and expand their parent nodes - const findSelectedNodes = (nodes: TreeNode[]): TreeNode[] => { - const selected: TreeNode[] = []; - - // Map to track if a node contains selected children - const containsSelectedNode = new Map, boolean>(); - - // First pass: find all selected nodes - const findSelected = (nodes: TreeNode[], parents: TreeNode[] = []): void => { - for (const node of nodes) { - // Check if this is a leaf node and matches a selected data type - if (node.selectable && node.data?.dataType && this.topicSelectionService.isSelected(node.data.dataType)) { - selected.push(node); - - // Mark all parents as containing selected nodes - parents.forEach((parent) => containsSelectedNode.set(parent, true)); - } - - // Continue searching children - if (node.children && node.children.length > 0) { - findSelected(node.children as TreeNode[], [...parents, node]); - } - } - }; - - // Find selected nodes and track their parents - findSelected(nodes); - - // Expand all parent nodes that contain selected children - containsSelectedNode.forEach((hasSelectedChild, node) => { - if (hasSelectedChild) { - node.expanded = true; - } - }); - - return selected; - }; - - this.selectedNodes = findSelectedNodes(this.treeNodes); + const nodes = dataTypesToNodes(this.dataTypes()); + this.treeNodes = mapNodesToTreeNodes(nodes, this.storage, this.storageSubscriptions); + this.selectedNodes = findSelectedTreeNodes(this.treeNodes, this.topicSelectionService); } ngOnDestroy(): void { - this.filterFormSubsription.unsubscribe(); + this.storageSubscriptions.forEach((sub) => sub.unsubscribe()); } clearSelections = () => { this.treeNodes.forEach((n) => (n.expanded = false)); - this.selectedDataTypesList = []; this.topicSelectionService.clearSelection(); this.selectedNodes = undefined; }; - transformDataTypeName(dataTypeName: string) { - return dataTypeNamePipe(dataTypeName); - } - nodeSelect(event: TreeNodeSelectEvent) { const dt = event.node.data?.dataType; - if (dt && !this.selectedDataTypesList.includes(dt)) { - this.selectedDataTypesList.push(dt); + if (dt) { this.topicSelectionService.addDataType(dt); } } - // this is so awesome and made by chat onRowClick(evt: MouseEvent, node: TreeNode) { if (node.children?.length !== 0) { - // parent row evt.preventDefault(); - evt.stopPropagation(); // keep selection engine out - node.expanded = !node.expanded; // toggle expanded state + evt.stopPropagation(); + node.expanded = !node.expanded; } - /* leaf rows fall through → normal multi‑selection behaviour */ } onNodeUnselect(event: TreeNodeUnSelectEvent) { const dt = event.node.data?.dataType; if (dt) { - this.selectedDataTypesList = this.selectedDataTypesList.filter((x) => x !== dt); this.topicSelectionService.removeDataType(dt); } } diff --git a/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-mobile/graph-sidebar-mobile.component.ts b/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-mobile/graph-sidebar-mobile.component.ts index 29eb3022..25ed2ec3 100644 --- a/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-mobile/graph-sidebar-mobile.component.ts +++ b/angular-client/src/pages/graph-page/graph-sidebar/graph-sidebar-mobile/graph-sidebar-mobile.component.ts @@ -1,13 +1,13 @@ import { AsyncPipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, OnInit, OnDestroy, inject, input } from '@angular/core'; -import { BehaviorSubject, Subscription } from 'rxjs'; +import { Subscription } from 'rxjs'; import { TreeNode, PrimeTemplate } from 'primeng/api'; import { TreeNodeSelectEvent, TreeNodeUnSelectEvent, Tree } from 'primeng/tree'; import { Sidebar } from 'primeng/sidebar'; import Storage from 'src/services/storage.service'; -import { decimalPipe } from 'src/utils/pipes.utils'; import { dataTypesToNodes } from 'src/utils/dataTypes.utils'; -import { DataType, Node } from 'src/utils/types.utils'; +import { mapNodesToTreeNodes, findSelectedTreeNodes, TreeNodeData } from 'src/utils/tree.utils'; +import { DataType } from 'src/utils/types.utils'; import { TopicSelectionService } from 'src/services/topic-selection.service'; import { ButtonComponent } from '../../../../components/argos-button/argos-button.component'; import TypographyComponent from 'src/components/typography/typography.component'; @@ -26,60 +26,15 @@ export default class GraphSidebarMobileComponent implements OnInit, OnDestroy { private storage = inject(Storage); sidebarVisible = false; - nodes: Node[] = []; - treeNodes: TreeNode[] = []; - selectedNodes?: TreeNode[]; + treeNodes: TreeNode[] = []; + selectedNodes?: TreeNode[]; private storageSubscriptions: Subscription[] = []; ngOnInit(): void { - this.nodes = dataTypesToNodes(this.dataTypes()); - - const mapToTreeNode = (node: Node): TreeNode => { - const displayValue = new BehaviorSubject('N/A'); - const isLeaf = node.nodes.value.length === 0; - if (isLeaf) { - // topicName has a trailing slash from dataTypesToNodes — strip it for storage lookup - this.storageSubscriptions.push( - this.storage.get(node.topicName.slice(0, -1)).subscribe((value) => { - displayValue.next(decimalPipe(value.values[0], 3).toFixed(3) + value.unit); - }) - ); - } - return { - label: node.name, - data: { ...node, displayValue }, - key: node.topicName, - children: node.nodes.value.map(mapToTreeNode), - selectable: isLeaf - }; - }; - - this.treeNodes = this.nodes.map(mapToTreeNode); - - // Sync existing selections - const findSelectedNodes = (nodes: TreeNode[]): TreeNode[] => { - const selected: TreeNode[] = []; - const containsSelected = new Map, boolean>(); - - const search = (nodes: TreeNode[], parents: TreeNode[] = []): void => { - for (const node of nodes) { - if (node.selectable && node.data?.dataType && this.topicSelectionService.isSelected(node.data.dataType)) { - selected.push(node); - parents.forEach((p) => containsSelected.set(p, true)); - } - if (node.children?.length) { - search(node.children as TreeNode[], [...parents, node]); - } - } - }; - - search(nodes); - containsSelected.forEach((_, node) => (node.expanded = true)); - return selected; - }; - - this.selectedNodes = findSelectedNodes(this.treeNodes); + const nodes = dataTypesToNodes(this.dataTypes()); + this.treeNodes = mapNodesToTreeNodes(nodes, this.storage, this.storageSubscriptions); + this.selectedNodes = findSelectedTreeNodes(this.treeNodes, this.topicSelectionService); } ngOnDestroy(): void { diff --git a/angular-client/src/pages/landing-page/landing-page-mobile/landing-page-mobile.component.css b/angular-client/src/pages/landing-page/landing-page-mobile/landing-page-mobile.component.css deleted file mode 100644 index e69de29b..00000000 diff --git a/angular-client/src/pages/landing-page/landing-page-mobile/landing-page-mobile.component.ts b/angular-client/src/pages/landing-page/landing-page-mobile/landing-page-mobile.component.ts index 25224963..daf3df78 100644 --- a/angular-client/src/pages/landing-page/landing-page-mobile/landing-page-mobile.component.ts +++ b/angular-client/src/pages/landing-page/landing-page-mobile/landing-page-mobile.component.ts @@ -15,7 +15,6 @@ import SidebarToggleComponent from 'src/components/sidebar-toggle/sidebar-toggle @Component({ selector: 'landing-page-mobile', templateUrl: './landing-page-mobile.component.html', - styleUrls: ['./landing-page-mobile.component.css'], changeDetection: ChangeDetectionStrategy.OnPush, imports: [ TypographyComponent, diff --git a/angular-client/src/utils/tree.utils.ts b/angular-client/src/utils/tree.utils.ts new file mode 100644 index 00000000..6328ce45 --- /dev/null +++ b/angular-client/src/utils/tree.utils.ts @@ -0,0 +1,71 @@ +import { BehaviorSubject, Subscription } from 'rxjs'; +import { TreeNode } from 'primeng/api'; +import Storage from 'src/services/storage.service'; +import { decimalPipe } from 'src/utils/pipes.utils'; +import { Node } from 'src/utils/types.utils'; +import { TopicSelectionService } from 'src/services/topic-selection.service'; + +export interface TreeNodeData extends Node { + displayValue: BehaviorSubject; +} + +/** + * Recursively maps Node[] to PrimeNG TreeNode[], subscribing leaf nodes + * to Storage for live value display. + * + * @returns The tree nodes. Caller must unsubscribe via the subscriptions array. + */ +export function mapNodesToTreeNodes( + nodes: Node[], + storage: Storage, + subscriptions: Subscription[], + precision = 3 +): TreeNode[] { + const mapToTreeNode = (node: Node): TreeNode => { + const displayValue = new BehaviorSubject('N/A'); + const isLeaf = node.nodes.value.length === 0; + if (isLeaf) { + // topicName has a trailing slash from dataTypesToNodes — strip it for storage lookup + subscriptions.push( + storage.get(node.topicName.slice(0, -1)).subscribe((value) => { + displayValue.next(decimalPipe(value.values[0], precision).toFixed(precision) + value.unit); + }) + ); + } + return { + label: node.name, + data: { ...node, displayValue }, + key: node.topicName, + children: node.nodes.value.map(mapToTreeNode), + selectable: isLeaf + }; + }; + return nodes.map(mapToTreeNode); +} + +/** + * Finds already-selected nodes in a tree and expands their parents. + */ +export function findSelectedTreeNodes( + treeNodes: TreeNode[], + selectionService: TopicSelectionService +): TreeNode[] { + const selected: TreeNode[] = []; + const containsSelected = new Map, boolean>(); + + const search = (nodes: TreeNode[], parents: TreeNode[] = []): void => { + for (const node of nodes) { + if (node.selectable && node.data?.dataType && selectionService.isSelected(node.data.dataType)) { + selected.push(node); + parents.forEach((p) => containsSelected.set(p, true)); + } + if (node.children?.length) { + search(node.children as TreeNode[], [...parents, node]); + } + } + }; + + search(treeNodes); + containsSelected.forEach((_, node) => (node.expanded = true)); + return selected; +}