Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/app/core/interfaces/widgets-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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*/
Expand Down
64 changes: 64 additions & 0 deletions src/app/core/services/image-asset.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
87 changes: 87 additions & 0 deletions src/app/core/services/image-asset.service.ts
Original file line number Diff line number Diff line change
@@ -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<HttpEvent<IImageAsset>> {
const form = new FormData();
form.append('file', file);
return this.http.post<IImageAsset>(this.imagesUrl(), form, { reportProgress: true, observe: 'events' });
}

list(): Observable<IImageAsset[]> {
return this.http.get<IImageAsset[]>(this.imagesUrl());
}

delete(id: string): Observable<{ ok: boolean }> {
return this.http.delete<{ ok: boolean }>(`${this.imagesUrl()}/${encodeURIComponent(id)}`);
}

cacheStats(): Observable<IImageCacheStats> {
return this.http.get<IImageCacheStats>(`${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}`;
}
}
15 changes: 15 additions & 0 deletions src/app/core/services/widget.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -157,6 +158,7 @@ export class WidgetService {
WidgetAisRadarComponent: WidgetAisRadarComponent,
WidgetLabelComponent: WidgetLabelComponent,
WidgetIframeComponent: WidgetIframeComponent,
WidgetImageComponent: WidgetImageComponent,
WidgetHorizonComponent: WidgetHorizonComponent,
WidgetHeelGaugeComponent: WidgetHeelGaugeComponent,
WidgetSteelGaugeComponent: WidgetSteelGaugeComponent,
Expand Down Expand Up @@ -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.',
Expand Down
37 changes: 37 additions & 0 deletions src/app/core/utils/kip-plugin-url.util.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
47 changes: 47 additions & 0 deletions src/app/core/utils/kip-plugin-url.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Resolves the base URL of the KIP Signal K plugin (`<server>/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;
}
7 changes: 7 additions & 0 deletions src/app/widgets/widget-image/widget-image.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<div class="image-widget-host" kipResizeObserver (kipResize)="onResize($event)" [style.background]="background()">
@if (imageUrl(); as url) {
<img class="image-widget-img" [src]="url" [alt]="altText()" [style.object-fit]="objectFit()" draggable="false">
} @else {
<div class="image-widget-empty">No image selected</div>
}
</div>
28 changes: 28 additions & 0 deletions src/app/widgets/widget-image/widget-image.component.scss
Original file line number Diff line number Diff line change
@@ -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;
}
69 changes: 69 additions & 0 deletions src/app/widgets/widget-image/widget-image.component.spec.ts
Original file line number Diff line number Diff line change
@@ -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<WidgetImageComponent>;
let component: WidgetImageComponent;
const options = signal<IWidgetSvcConfig | undefined>(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');
});
});
Loading