diff --git a/src/app/core/interfaces/widgets-interface.ts b/src/app/core/interfaces/widgets-interface.ts index 356da3945..321d1f559 100644 --- a/src/app/core/interfaces/widgets-interface.ts +++ b/src/app/core/interfaces/widgets-interface.ts @@ -386,6 +386,15 @@ export interface IWidgetSvcConfig { /** Used by IFrame widget: allow input on iframe or not */ allowInput?: boolean; + /** Used by the Image widget: selected shared image asset + display options. */ + image?: { + imageId?: string | null; + imageFit?: 'contain' | 'cover'; + altText?: string; + /** Letterbox/background fill; null = transparent (dashboard shows through). */ + backgroundColor?: string | null; + }; + /** Use by racetimer widget */ timerLength?: number; /** The next dashboard to display when the racer-timer-widget counts to 0 and the boat is not OCS*/ diff --git a/src/app/core/services/image-asset.service.spec.ts b/src/app/core/services/image-asset.service.spec.ts new file mode 100644 index 000000000..f4cdfb46c --- /dev/null +++ b/src/app/core/services/image-asset.service.spec.ts @@ -0,0 +1,64 @@ +import { TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { BehaviorSubject, of } from 'rxjs'; +import { HttpClient } from '@angular/common/http'; +import { ImageAssetService } from './image-asset.service'; +import { SignalKConnectionService } from './signalk-connection.service'; + +function setup(httpServiceUrl: string | null, configuredUrl = '') { + const http = { post: vi.fn(() => of({})), get: vi.fn(() => of([])), delete: vi.fn(() => of({ ok: true })) }; + const connection = { + serverServiceEndpoint$: new BehaviorSubject<{ httpServiceUrl: string | null }>({ httpServiceUrl }), + signalKURL: { url: configuredUrl } + }; + TestBed.configureTestingModule({ + providers: [ + ImageAssetService, + { provide: HttpClient, useValue: http }, + { provide: SignalKConnectionService, useValue: connection } + ] + }); + return { service: TestBed.inject(ImageAssetService), http }; +} + +describe('ImageAssetService', () => { + beforeEach(() => TestBed.resetTestingModule()); + + it('becomes ready and builds variant URLs snapped to a container width', () => { + const { service } = setup('http://host:3000/signalk/v1/api/'); + expect(service.ready).toBe(true); + expect(service.urlFor('abc', 300, 1)).toBe('http://host:3000/plugins/kip/images/abc?w=320'); + expect(service.urlFor('abc', 320, 2)).toBe('http://host:3000/plugins/kip/images/abc?w=640'); + expect(service.urlFor(null, 300)).toBeNull(); + }); + + it('is not ready and yields null URLs before an endpoint is known', () => { + const { service } = setup(null); + expect(service.ready).toBe(false); + expect(service.urlFor('abc', 300)).toBeNull(); + }); + + it('posts an upload as multipart FormData to the images endpoint', () => { + const { service, http } = setup('http://host:3000/signalk/v1/api/'); + const file = new File([new Uint8Array([1, 2, 3])], 'map.png', { type: 'image/png' }); + service.upload(file).subscribe(); + expect(http.post).toHaveBeenCalledTimes(1); + const [url, body, opts] = http.post.mock.calls[0] as unknown[]; + expect(url).toBe('http://host:3000/plugins/kip/images'); + expect(body).toBeInstanceOf(FormData); + expect((body as FormData).get('file')).toBe(file); + expect(opts).toMatchObject({ reportProgress: true, observe: 'events' }); + }); + + it('targets the right endpoints for list/delete/cache/purge', () => { + const { service, http } = setup('http://host:3000/signalk/v1/api/'); + service.list().subscribe(); + expect(http.get).toHaveBeenCalledWith('http://host:3000/plugins/kip/images'); + service.delete('id-1').subscribe(); + expect(http.delete).toHaveBeenCalledWith('http://host:3000/plugins/kip/images/id-1'); + service.cacheStats().subscribe(); + expect(http.get).toHaveBeenCalledWith('http://host:3000/plugins/kip/images/cache'); + service.purgeCache().subscribe(); + expect(http.delete).toHaveBeenCalledWith('http://host:3000/plugins/kip/images/cache'); + }); +}); diff --git a/src/app/core/services/image-asset.service.ts b/src/app/core/services/image-asset.service.ts new file mode 100644 index 000000000..4906c2ced --- /dev/null +++ b/src/app/core/services/image-asset.service.ts @@ -0,0 +1,87 @@ +import { DestroyRef, Injectable, inject } from '@angular/core'; +import { HttpClient, HttpEvent } from '@angular/common/http'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Observable } from 'rxjs'; +import { SignalKConnectionService } from './signalk-connection.service'; +import { resolveKipPluginBaseUrl, snapImageWidth } from '../utils/kip-plugin-url.util'; + +export interface IImageAsset { + id: string; + name: string; + format: string; + width: number | null; + height: number | null; + bytes: number; + animated: boolean; + createdAt: string; + url?: string; +} + +export interface IImageCacheStats { + bytes: number; + files: number; +} + +/** + * Client for the KIP image-asset plugin endpoints (upload / list / delete / serve / cache). + * All requests are auto-authenticated by the app's authentication interceptor (JWT). + */ +@Injectable({ providedIn: 'root' }) +export class ImageAssetService { + private readonly http = inject(HttpClient); + private readonly connection = inject(SignalKConnectionService); + private readonly destroyRef = inject(DestroyRef); + private pluginBaseUrl: string | null = null; + + constructor() { + this.connection.serverServiceEndpoint$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(endpoint => { + this.pluginBaseUrl = resolveKipPluginBaseUrl(endpoint?.httpServiceUrl ?? null, this.connection.signalKURL?.url); + }); + } + + get ready(): boolean { + return this.pluginBaseUrl !== null; + } + + private imagesUrl(): string { + if (!this.pluginBaseUrl) { + throw new Error('Signal K connection is not ready'); + } + return `${this.pluginBaseUrl}images`; + } + + /** Upload a file; emits HttpEvents so callers can show progress. */ + upload(file: File): Observable> { + const form = new FormData(); + form.append('file', file); + return this.http.post(this.imagesUrl(), form, { reportProgress: true, observe: 'events' }); + } + + list(): Observable { + return this.http.get(this.imagesUrl()); + } + + delete(id: string): Observable<{ ok: boolean }> { + return this.http.delete<{ ok: boolean }>(`${this.imagesUrl()}/${encodeURIComponent(id)}`); + } + + cacheStats(): Observable { + return this.http.get(`${this.imagesUrl()}/cache`); + } + + purgeCache(): Observable<{ ok: boolean }> { + return this.http.delete<{ ok: boolean }>(`${this.imagesUrl()}/cache`); + } + + /** Build a cache-friendly variant URL matched to a container width (null if unset/not ready). */ + urlFor(id: string | null | undefined, cssWidth?: number | null, devicePixelRatio?: number): string | null { + if (!id || !this.pluginBaseUrl) { + return null; + } + const dpr = devicePixelRatio ?? (typeof window !== 'undefined' ? window.devicePixelRatio : 1); + const w = snapImageWidth(cssWidth, dpr); + return `${this.pluginBaseUrl}images/${encodeURIComponent(id)}?w=${w}`; + } +} diff --git a/src/app/core/services/widget.service.ts b/src/app/core/services/widget.service.ts index 6e64c6487..43adde7e1 100644 --- a/src/app/core/services/widget.service.ts +++ b/src/app/core/services/widget.service.ts @@ -16,6 +16,7 @@ import { WidgetPositionComponent } from '../../widgets/widget-position/widget-po import { WidgetAisRadarComponent } from '../../widgets/widget-ais-radar/widget-ais-radar.component'; import { WidgetLabelComponent } from '../../widgets/widget-label/widget-label.component'; import { WidgetIframeComponent } from '../../widgets/widget-iframe/widget-iframe.component'; +import { WidgetImageComponent } from '../../widgets/widget-image/widget-image.component'; import { WidgetHorizonComponent } from '../../widgets/widget-horizon/widget-horizon.component'; import { WidgetHeelGaugeComponent } from '../../widgets/widget-heel-gauge/widget-heel-gauge.component'; import { WidgetSteelGaugeComponent } from '../../widgets/widget-gauge-steel/widget-gauge-steel.component'; @@ -157,6 +158,7 @@ export class WidgetService { WidgetAisRadarComponent: WidgetAisRadarComponent, WidgetLabelComponent: WidgetLabelComponent, WidgetIframeComponent: WidgetIframeComponent, + WidgetImageComponent: WidgetImageComponent, WidgetHorizonComponent: WidgetHorizonComponent, WidgetHeelGaugeComponent: WidgetHeelGaugeComponent, WidgetSteelGaugeComponent: WidgetSteelGaugeComponent, @@ -558,6 +560,19 @@ export class WidgetService { selector: 'widget-iframe', componentClassName: 'WidgetIframeComponent', }, + { + name: 'Image', + description: 'Displays an uploaded image (e.g. a diagram of where safety equipment is stowed) stored on the Signal K server, scaled to fit while preserving aspect ratio. Upload images or pick from the shared library in the widget options.', + icon: '', + minWidth: 2, + minHeight: 2, + defaultWidth: 4, + defaultHeight: 6, + category: 'Core', + requiredPlugins: [], + selector: 'widget-image', + componentClassName: 'WidgetImageComponent', + }, { name: 'Tutorial', description: 'An instructional widget that guides new users through basic navigation, gestures, and dashboard editing steps.', diff --git a/src/app/core/utils/kip-plugin-url.util.spec.ts b/src/app/core/utils/kip-plugin-url.util.spec.ts new file mode 100644 index 000000000..96f2e5437 --- /dev/null +++ b/src/app/core/utils/kip-plugin-url.util.spec.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { resolveKipPluginBaseUrl, snapImageWidth, IMAGE_WIDTH_ALLOWLIST } from './kip-plugin-url.util'; + +describe('resolveKipPluginBaseUrl', () => { + it('prefers the configured URL', () => { + expect(resolveKipPluginBaseUrl('http://x/signalk/v1/api/', 'https://boat.local:3443')).toBe('https://boat.local:3443/plugins/kip/'); + expect(resolveKipPluginBaseUrl(null, 'https://boat.local/')).toBe('https://boat.local/plugins/kip/'); + }); + + it('derives the plugin base from the v1/v2 API URL by stripping the signalk suffix', () => { + expect(resolveKipPluginBaseUrl('http://host:3000/signalk/v1/api/')).toBe('http://host:3000/plugins/kip/'); + expect(resolveKipPluginBaseUrl('http://host:3000/signalk/v2/api')).toBe('http://host:3000/plugins/kip/'); + expect(resolveKipPluginBaseUrl('http://host:3000/signalk')).toBe('http://host:3000/plugins/kip/'); + }); + + it('returns null when nothing is known', () => { + expect(resolveKipPluginBaseUrl(null)).toBeNull(); + expect(resolveKipPluginBaseUrl(undefined, '')).toBeNull(); + }); +}); + +describe('snapImageWidth', () => { + const max = IMAGE_WIDTH_ALLOWLIST[IMAGE_WIDTH_ALLOWLIST.length - 1]; + + it('snaps up to the nearest allow-listed width, accounting for DPR', () => { + expect(snapImageWidth(100)).toBe(160); + expect(snapImageWidth(320)).toBe(320); + expect(snapImageWidth(330)).toBe(640); + expect(snapImageWidth(320, 2)).toBe(640); // 320 css * 2 dpr = 640 + }); + + it('uses the canonical max for unknown/zero/oversized widths', () => { + expect(snapImageWidth(undefined)).toBe(max); + expect(snapImageWidth(0)).toBe(max); + expect(snapImageWidth(99999)).toBe(max); + }); +}); diff --git a/src/app/core/utils/kip-plugin-url.util.ts b/src/app/core/utils/kip-plugin-url.util.ts new file mode 100644 index 000000000..1a38224f6 --- /dev/null +++ b/src/app/core/utils/kip-plugin-url.util.ts @@ -0,0 +1,47 @@ +/** + * Resolves the base URL of the KIP Signal K plugin (`/plugins/kip/`) from the connection + * endpoint, mirroring the logic in kip-series-api-client.service so both clients agree. + * + * @param httpServiceUrl the server's v1 API URL (e.g. `http://host:3000/signalk/v1/api/`) + * @param configuredUrl the user-configured Signal K URL, if any (takes precedence) + */ +export function resolveKipPluginBaseUrl(httpServiceUrl: string | null | undefined, configuredUrl?: string | null): string | null { + const configured = configuredUrl?.trim(); + if (configured) { + const base = configured.endsWith('/') ? configured.slice(0, -1) : configured; + return `${base}/plugins/kip/`; + } + if (!httpServiceUrl) { + return null; + } + const normalized = httpServiceUrl.endsWith('/') ? httpServiceUrl.slice(0, -1) : httpServiceUrl; + const root = normalized + .replace(/\/signalk\/v2\/api$/, '') + .replace(/\/signalk\/v1\/api$/, '') + .replace(/\/signalk\/v2$/, '') + .replace(/\/signalk\/v1$/, '') + .replace(/\/signalk$/, ''); + return `${root}/plugins/kip/`; +} + +/** + * Allowed image variant widths. Must match the server's allow-list so the client requests stable, + * cache-friendly URLs (the server snaps too, but matching avoids browser-cache misses). + */ +export const IMAGE_WIDTH_ALLOWLIST: readonly number[] = [160, 320, 640, 960, 1280, 1920, 2560]; + +/** Snap a CSS width (times device pixel ratio) up to the nearest allow-listed variant width. */ +export function snapImageWidth(cssWidth?: number | null, devicePixelRatio = 1): number { + const dpr = devicePixelRatio && devicePixelRatio > 0 ? devicePixelRatio : 1; + const target = cssWidth && cssWidth > 0 ? cssWidth * dpr : 0; + const max = IMAGE_WIDTH_ALLOWLIST[IMAGE_WIDTH_ALLOWLIST.length - 1]; + if (!target) { + return max; + } + for (const w of IMAGE_WIDTH_ALLOWLIST) { + if (w >= target) { + return w; + } + } + return max; +} diff --git a/src/app/widgets/widget-image/widget-image.component.html b/src/app/widgets/widget-image/widget-image.component.html new file mode 100644 index 000000000..7a06cc5e3 --- /dev/null +++ b/src/app/widgets/widget-image/widget-image.component.html @@ -0,0 +1,7 @@ +
+ @if (imageUrl(); as url) { + + } @else { +
No image selected
+ } +
diff --git a/src/app/widgets/widget-image/widget-image.component.scss b/src/app/widgets/widget-image/widget-image.component.scss new file mode 100644 index 000000000..db3ad1bc8 --- /dev/null +++ b/src/app/widgets/widget-image/widget-image.component.scss @@ -0,0 +1,28 @@ +:host { + display: block; + width: 100%; + height: 100%; +} + +.image-widget-host { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.image-widget-img { + width: 100%; + height: 100%; + display: block; + // object-fit is set from config (contain | cover) to scale while preserving aspect ratio. +} + +.image-widget-empty { + color: var(--kip-contrast-dim-color, rgba(255, 255, 255, 0.5)); + font-size: 0.9em; + text-align: center; + padding: 0.5em; +} diff --git a/src/app/widgets/widget-image/widget-image.component.spec.ts b/src/app/widgets/widget-image/widget-image.component.spec.ts new file mode 100644 index 000000000..41e595d03 --- /dev/null +++ b/src/app/widgets/widget-image/widget-image.component.spec.ts @@ -0,0 +1,69 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { signal } from '@angular/core'; +import { WidgetImageComponent } from './widget-image.component'; +import { WidgetRuntimeDirective } from '../../core/directives/widget-runtime.directive'; +import { ImageAssetService } from '../../core/services/image-asset.service'; +import type { IWidgetSvcConfig } from '../../core/interfaces/widgets-interface'; +import type { ITheme } from '../../core/services/app-service'; + +describe('WidgetImageComponent', () => { + let fixture: ComponentFixture; + let component: WidgetImageComponent; + const options = signal(undefined); + + const runtimeMock = { options }; + const imagesMock = { + urlFor: (id: string | null | undefined, w?: number | null) => + id ? `http://host/plugins/kip/images/${id}?w=${w || 2560}` : null + }; + + beforeEach(async () => { + options.set({ image: { imageId: null, imageFit: 'contain', altText: '', backgroundColor: null } }); + await TestBed.configureTestingModule({ + imports: [WidgetImageComponent], + providers: [ + { provide: WidgetRuntimeDirective, useValue: runtimeMock }, + { provide: ImageAssetService, useValue: imagesMock } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(WidgetImageComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('id', 'w1'); + fixture.componentRef.setInput('type', 'widget-image'); + fixture.componentRef.setInput('theme', {} as ITheme); + }); + + const api = () => component as unknown as { + imageUrl: () => string | null; + background: () => string; + objectFit: () => string; + }; + + it('shows the empty state when no image is selected', () => { + fixture.detectChanges(); + expect(api().imageUrl()).toBeNull(); + const el = fixture.nativeElement as HTMLElement; + expect(el.querySelector('.image-widget-empty')).toBeTruthy(); + expect(el.querySelector('img')).toBeFalsy(); + }); + + it('renders the configured image via the asset service URL with the chosen object-fit', () => { + options.set({ image: { imageId: 'img-1', imageFit: 'cover', altText: 'Safety map', backgroundColor: '#000' } }); + fixture.detectChanges(); + const img = (fixture.nativeElement as HTMLElement).querySelector('img'); + expect(img).toBeTruthy(); + expect(img!.getAttribute('src')).toContain('/plugins/kip/images/img-1?w='); + expect(img!.getAttribute('alt')).toBe('Safety map'); + expect(img!.style.objectFit).toBe('cover'); + }); + + it('defaults to a transparent background and contain fit, and reflects a configured color', () => { + fixture.detectChanges(); + expect(api().background()).toBe('transparent'); + expect(api().objectFit()).toBe('contain'); + options.set({ image: { imageId: 'x', backgroundColor: '#123456' } }); + expect(api().background()).toBe('#123456'); + }); +}); diff --git a/src/app/widgets/widget-image/widget-image.component.ts b/src/app/widgets/widget-image/widget-image.component.ts new file mode 100644 index 000000000..c75b916d9 --- /dev/null +++ b/src/app/widgets/widget-image/widget-image.component.ts @@ -0,0 +1,48 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core'; +import { IWidgetSvcConfig } from '../../core/interfaces/widgets-interface'; +import { WidgetRuntimeDirective } from '../../core/directives/widget-runtime.directive'; +import { KipResizeObserverDirective, IKipResizeEvent } from '../../core/directives/kip-resize-observer.directive'; +import { ImageAssetService } from '../../core/services/image-asset.service'; +import { ITheme } from '../../core/services/app-service'; + +/** + * Displays a user-uploaded image asset (stored on the Signal K server) scaled to fit the widget + * while preserving aspect ratio (object-fit), over a configurable background (transparent by default). + * Requests a variant matched to the container width so a small widget doesn't fetch a full-res image. + */ +@Component({ + selector: 'widget-image', + templateUrl: './widget-image.component.html', + styleUrls: ['./widget-image.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [KipResizeObserverDirective] +}) +export class WidgetImageComponent { + public id = input.required(); + public type = input.required(); + public theme = input.required(); + + protected readonly runtime = inject(WidgetRuntimeDirective); + private readonly images = inject(ImageAssetService); + + public static readonly DEFAULT_CONFIG: IWidgetSvcConfig = { + image: { imageId: null, imageFit: 'contain', altText: '', backgroundColor: null } + }; + + private readonly containerWidth = signal(0); + + protected readonly imageConfig = computed(() => this.runtime.options()?.image ?? null); + protected readonly altText = computed(() => this.imageConfig()?.altText ?? ''); + protected readonly objectFit = computed(() => this.imageConfig()?.imageFit ?? 'contain'); + protected readonly background = computed(() => this.imageConfig()?.backgroundColor ?? 'transparent'); + + protected readonly imageUrl = computed(() => { + const id = this.imageConfig()?.imageId; + if (!id) return null; + return this.images.urlFor(id, this.containerWidth()); + }); + + protected onResize(event: IKipResizeEvent): void { + this.containerWidth.set(Math.round(event.width)); + } +}