reloadOnChunkLoadError(e.error)),
+ ),
provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode() && environment.service_worker,
registrationStrategy: 'registerWhenStable:30000',
diff --git a/apps/control/src/test-setup.ts b/apps/control/src/test-setup.ts
deleted file mode 100644
index cfd12bbcfff..00000000000
--- a/apps/control/src/test-setup.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { installTestStorage } from 'libs/common/src/test-storage';
-import { defineGlobalsInjections, Spectator } from '@ngneat/spectator';
-import { setNotifyOutlet } from '@placeos/common';
-import { TranslatePipe } from '@placeos/components';
-import { MockPipe } from 'ng-mocks';
-import { TextDecoder, TextEncoder } from 'util';
-
-global.TextEncoder = TextEncoder;
-global.TextDecoder = TextDecoder;
-installTestStorage();
-
-// Under the zoneless unit-test builder, `fixture.detectChanges()` only
-// refreshes views that were explicitly marked dirty. Plain host-property
-// changes (`setHostInput`), overridden getters and reassigned signals do not
-// mark the root view, so the main CD pass skips them while dev-mode
-// `checkNoChanges` still catches the new value -> NG0100. Emulate zone.js's
-// full dirty-check by marking the fixture root for check before every
-// `detectChanges()`.
-const _dom_spectator_proto: any = Object.getPrototypeOf(Spectator.prototype);
-const _original_detect_changes = _dom_spectator_proto.detectChanges;
-_dom_spectator_proto.detectChanges = function () {
- try {
- this.fixture?.componentRef?.changeDetectorRef?.markForCheck();
- } catch {
- // ignore - fixture may not expose a componentRef
- }
- return _original_detect_changes.apply(this, arguments);
-};
-
-setNotifyOutlet(null, true);
-
-defineGlobalsInjections({
- declarations: [MockPipe(TranslatePipe)],
-});
diff --git a/apps/control/tsconfig.spec.json b/apps/control/tsconfig.spec.json
index 94be5ce7ec1..65191cc4526 100644
--- a/apps/control/tsconfig.spec.json
+++ b/apps/control/tsconfig.spec.json
@@ -7,7 +7,7 @@
"moduleResolution": "bundler",
"isolatedModules": true
},
- "files": ["src/test-setup.ts"],
+ "files": ["../../shared/test-setup-base.ts", "../../shared/test-setup.ts"],
"include": ["**/*.spec.ts", "**/*.d.ts"],
"angularCompilerOptions": {
"extendedDiagnostics": {
diff --git a/apps/enrolment/ngsw-config.json b/apps/enrolment/ngsw-config.json
deleted file mode 100644
index 0ec7b103706..00000000000
--- a/apps/enrolment/ngsw-config.json
+++ /dev/null
@@ -1,30 +0,0 @@
-{
- "$schema": "../../node_modules/@angular/service-worker/config/schema.json",
- "index": "/index.html",
- "assetGroups": [
- {
- "name": "app",
- "installMode": "prefetch",
- "resources": {
- "files": [
- "/favicon.ico",
- "/index.html",
- "/manifest.webmanifest",
- "/*.css",
- "/*.js"
- ]
- }
- },
- {
- "name": "assets",
- "installMode": "lazy",
- "updateMode": "prefetch",
- "resources": {
- "files": [
- "/assets/**",
- "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
- ]
- }
- }
- ]
-}
diff --git a/apps/enrolment/project.json b/apps/enrolment/project.json
index 195394bb6b2..0b3356f0fd3 100644
--- a/apps/enrolment/project.json
+++ b/apps/enrolment/project.json
@@ -8,7 +8,7 @@
"targets": {
"build": {
"executor": "@angular/build:application",
- "outputs": ["{options.outputPath.base}"],
+ "outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/enrolment",
@@ -43,7 +43,7 @@
}
],
"scripts": [],
- "serviceWorker": "apps/enrolment/ngsw-config.json",
+ "serviceWorker": "shared/ngsw-config.json",
"browser": "apps/enrolment/src/main.ts"
},
"configurations": {
@@ -60,7 +60,7 @@
"with": "apps/enrolment/src/environments/settings.ts"
}
],
- "serviceWorker": "apps/enrolment/ngsw-config.json"
+ "serviceWorker": "shared/ngsw-config.json"
},
"production": {
"sourceMap": {
@@ -81,10 +81,6 @@
}
],
"fileReplacements": [
- {
- "replace": "libs/mocks/src/index.ts",
- "with": "libs/mocks/src/empty.ts"
- },
{
"replace": "apps/enrolment/src/environments/environment.ts",
"with": "apps/enrolment/src/environments/environment.prod.ts"
diff --git a/apps/enrolment/src/app/app.component.ts b/apps/enrolment/src/app/app.component.ts
index 0aad1da6e7d..0984092cbe3 100644
--- a/apps/enrolment/src/app/app.component.ts
+++ b/apps/enrolment/src/app/app.component.ts
@@ -1,4 +1,3 @@
-import { Clipboard } from '@angular/cdk/clipboard';
import { Component, ErrorHandler, inject, OnInit } from '@angular/core';
import { SwUpdate } from '@angular/service-worker';
import { SettingsDebugPanelLauncherComponent } from '@placeos/components/settings-debug';
@@ -11,9 +10,7 @@ import {
initialisationFailure,
initSentryFromSettings,
lazySnackbar,
- log,
markInitialisationComplete,
- OrganisationService,
retryInitialisation,
setAppName,
setNotifyOutlet,
@@ -29,7 +26,6 @@ import {
ServiceWorkerUpdateCardComponent,
} from '@placeos/components';
-import * as MOCKS from '@placeos/mocks';
import { PlaceAuthority, token } from '@placeos/ts-client';
@Component({
selector: 'app-root',
@@ -80,10 +76,8 @@ import { PlaceAuthority, token } from '@placeos/ts-client';
})
export class AppComponent extends AsyncHandler implements OnInit {
private _settings = inject(SettingsService);
- private _org = inject(OrganisationService);
private _cache = inject(SwUpdate);
private _snackbar = lazySnackbar();
- private _clipboard = inject(Clipboard);
/** Reports startup failures to the console and Sentry */
private _error_handler = inject(ErrorHandler);
public readonly initialisation_error = initialisationFailure();
@@ -99,7 +93,6 @@ export class AppComponent extends AsyncHandler implements OnInit {
}
public async ngOnInit() {
- log('APP', 'MOCKS:', MOCKS);
setNotifyOutlet(this._snackbar);
// Listen for service worker events before any async setup so update
// notifications emitted during initialisation are not missed.
diff --git a/apps/enrolment/src/app/enrolment-error.component.ts b/apps/enrolment/src/app/enrolment-error.component.ts
index acd058f8bbd..9a3a0e2768e 100644
--- a/apps/enrolment/src/app/enrolment-error.component.ts
+++ b/apps/enrolment/src/app/enrolment-error.component.ts
@@ -32,6 +32,14 @@ import { EnrolmentStateService } from './enrolment-state.service';
Meeting was cancelled, please check your calendar for
updates.
}
+ @case ('checkin') {
+ We could not check you in. Reload the page and try
+ again.
+ }
+ @case ('update') {
+ We could not save your details. Reload the page and try
+ again.
+ }
}
diff --git a/apps/enrolment/src/app/enrolment-state.service.ts b/apps/enrolment/src/app/enrolment-state.service.ts
index 568c5005731..a37a8e1387c 100644
--- a/apps/enrolment/src/app/enrolment-state.service.ts
+++ b/apps/enrolment/src/app/enrolment-state.service.ts
@@ -1,6 +1,6 @@
import { Injectable, computed, resource, signal } from '@angular/core';
import { email, form, required, validate } from '@angular/forms/signals';
-import { CalendarEvent, patchSignalModel } from '@placeos/common';
+import { CalendarEvent, parseJWT, patchSignalModel } from '@placeos/common';
import { checkinEventGuest, showEvent } from '@placeos/events';
import { setToken } from '@placeos/ts-client';
import { showGuest, updateGuest } from '@placeos/users';
@@ -20,20 +20,6 @@ export interface EnrolmentFormValue {
accepted_terms_conditions: boolean;
}
-export function parseJWT(token: string) {
- const base64Url = token.split('.')[1];
- const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
- const jsonPayload = decodeURIComponent(
- atob(base64)
- .split('')
- .map((c) => {
- return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
- })
- .join(''),
- );
- return JSON.parse(jsonPayload);
-}
-
@Injectable({
providedIn: 'root',
})
@@ -111,21 +97,34 @@ export class EnrolmentStateService {
this._loading.set('');
}
+ /** Saves edited details, then checks the guest in. Failures show the error view. */
public async checkin() {
if (this.form().dirty()) await this.updateGuest();
+ if (this._error()) return;
this._loading.set('Checking you in...');
- await checkinEventGuest(this._event_id(), this._guest_id(), true);
- this._view.set('complete');
- this._loading.set('');
+ try {
+ await checkinEventGuest(this._event_id(), this._guest_id(), true);
+ this._view.set('complete');
+ } catch {
+ this.setError('checkin');
+ } finally {
+ this._loading.set('');
+ }
}
+ /** Saves the guest's edited details. A failure shows the error view. */
public async updateGuest() {
this.form().markAsTouched();
if (!this.form().valid()) return;
this._loading.set('Updating your details...');
const details = this._guest.value();
- await updateGuest(details.id, { ...details, ...this.model() });
- this._loading.set('');
+ try {
+ await updateGuest(details.id, { ...details, ...this.model() });
+ } catch {
+ this.setError('update');
+ } finally {
+ this._loading.set('');
+ }
}
public handleUserToken(token: string) {
diff --git a/apps/enrolment/src/app/enrolment.component.ts b/apps/enrolment/src/app/enrolment.component.ts
index 989172d5059..dffc3a10250 100644
--- a/apps/enrolment/src/app/enrolment.component.ts
+++ b/apps/enrolment/src/app/enrolment.component.ts
@@ -2,11 +2,7 @@ import { CommonModule } from '@angular/common';
import { Component, computed, inject, OnInit } from '@angular/core';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { ActivatedRoute } from '@angular/router';
-import {
- AsyncHandler,
- OrganisationService,
- SettingsService,
-} from '@placeos/common';
+import { AsyncHandler, settingSignal, SettingsService } from '@placeos/common';
import {
AuthenticatedImageDirective,
IconComponent,
@@ -23,7 +19,7 @@ import { EnrolmentStateService } from './enrolment-state.service';
- @let logo_path = logo()?.src || logo();
+ @let logo_path = logo();
![]()
@@ -97,20 +94,22 @@ export class EnrolmentComponent extends AsyncHandler implements OnInit {
private _state = inject(EnrolmentStateService);
private _settings = inject(SettingsService);
private _route = inject(ActivatedRoute);
- private _org = inject(OrganisationService);
public readonly loading = this._state.loading;
public readonly view = this._state.view;
public readonly event = this._state.event;
+ /**
+ * Image URL of the header logo. The header uses the opposite logo to the
+ * theme: `logo_dark` with the light theme and `logo_light` with the dark theme.
+ */
public readonly logo = computed(() => {
- // Recompute when the active building changes
- this._org.active_building();
- return (
- (this._settings.theme === 'dark'
- ? this._settings.get('app.logo_light')
- : this._settings.get('app.logo_dark')) || {}
- );
+ const logo = settingSignal
(
+ this._settings.theme_signal() === 'dark'
+ ? 'logo_light'
+ : 'logo_dark',
+ )();
+ return typeof logo === 'string' ? logo : logo?.src || '';
});
public ngOnInit() {
diff --git a/apps/enrolment/src/tests/enrolment-state.service.spec.ts b/apps/enrolment/src/tests/enrolment-state.service.spec.ts
index fc4f49da24a..717eb52d649 100644
--- a/apps/enrolment/src/tests/enrolment-state.service.spec.ts
+++ b/apps/enrolment/src/tests/enrolment-state.service.spec.ts
@@ -6,10 +6,7 @@ import {
import { getUnixTime } from 'date-fns';
import * as ts_client from '@placeos/ts-client';
-import {
- EnrolmentStateService,
- parseJWT,
-} from '../app/enrolment-state.service';
+import { EnrolmentStateService } from '../app/enrolment-state.service';
// Assert at the `@placeos/ts-client` boundary. `showGuest`/`showEvent`/
// `checkinEventGuest`/`updateGuest` are workspace functions that run for real
@@ -100,22 +97,6 @@ describe('EnrolmentStateService', () => {
expect(ts_client.get).not.toHaveBeenCalled();
});
- describe('parseJWT', () => {
- it('should decode the payload segment of a token', () => {
- expect(
- parseJWT(userToken({ exp: 42, u: { e: 'a@b.com' } })),
- ).toEqual({ exp: 42, u: { e: 'a@b.com' } });
- });
-
- it('should decode base64url-specific characters', () => {
- // '>>>' and '???' encode to base64 containing '+' and '/', which the
- // base64url variant replaces with '-' and '_'
- const payload = { note: '>>>???' };
- expect(userToken(payload)).toMatch(/[-_]/);
- expect(parseJWT(userToken(payload))).toEqual(payload);
- });
- });
-
describe('handleUserToken', () => {
it('should authenticate and load the guest named in the token', async () => {
spectator.service.handleUserToken(validToken());
@@ -323,5 +304,15 @@ describe('EnrolmentStateService', () => {
await spectator.service.checkin();
expect(ts_client.patch).not.toHaveBeenCalled();
});
+
+ it('should show the error view and stop loading when check-in fails', async () => {
+ (ts_client.post as any).mockRejectedValue(new Error('offline'));
+
+ await spectator.service.checkin();
+
+ expect(spectator.service.error()).toBe('checkin');
+ expect(spectator.service.view()).toBe('error');
+ expect(spectator.service.loading()).toBe('');
+ });
});
});
diff --git a/apps/enrolment/src/tests/enrolment.component.spec.ts b/apps/enrolment/src/tests/enrolment.component.spec.ts
index b4d9d627fa6..eab93b37525 100644
--- a/apps/enrolment/src/tests/enrolment.component.spec.ts
+++ b/apps/enrolment/src/tests/enrolment.component.spec.ts
@@ -4,8 +4,8 @@ import {
DeferBlockState,
} from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
-import { Spectator, createComponentFactory } from '@ngneat/spectator/vitest';
-import { OrganisationService, SettingsService } from '@placeos/common';
+import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest';
+import { settingSignal, SettingsService } from '@placeos/common';
import { getUnixTime } from 'date-fns';
import { MockProvider } from 'ng-mocks';
import { BehaviorSubject } from 'rxjs';
@@ -31,8 +31,7 @@ function userToken() {
function param_map(params: Record): unknown {
return {
- has: (key: string) =>
- Object.prototype.hasOwnProperty.call(params, key),
+ has: (key: string) => Object.prototype.hasOwnProperty.call(params, key),
get: (key: string) => (key in params ? params[key] : null),
getAll: (key: string) => (key in params ? [params[key]] : []),
get keys() {
@@ -46,21 +45,14 @@ describe('EnrolmentComponent', () => {
let state: EnrolmentStateService;
let params: BehaviorSubject;
let query_params: BehaviorSubject;
- let settings_map: Record;
- let theme: string;
+ const theme = signal('light');
const createComponent = createComponentFactory({
component: EnrolmentComponent,
providers: [
MockProvider(SettingsService, {
- get: vi.fn((key: string) => settings_map[key]),
signal: vi.fn(() => signal(false)),
- get theme() {
- return theme;
- },
- } as any),
- MockProvider(OrganisationService, {
- active_building: signal({ id: 'bld-1' }),
+ theme_signal: theme,
} as any),
{
provide: ActivatedRoute,
@@ -83,11 +75,9 @@ describe('EnrolmentComponent', () => {
beforeEach(() => {
vi.useFakeTimers();
- theme = 'light';
- settings_map = {
- 'app.logo_dark': { src: 'assets/logo-dark.svg' },
- 'app.logo_light': { src: 'assets/logo-light.svg' },
- };
+ theme.set('light');
+ settingSignal('logo_dark').set({ src: 'assets/logo-dark.svg' });
+ settingSignal('logo_light').set('assets/logo-light.svg');
params = new BehaviorSubject(param_map({}));
query_params = new BehaviorSubject(param_map({}));
(ts_client.get as any).mockReset();
@@ -182,8 +172,7 @@ describe('EnrolmentComponent', () => {
});
it('should use the light logo for the dark theme', () => {
- theme = 'dark';
- spectator = createComponent();
+ theme.set('dark');
spectator.detectChanges();
expect(spectator.query('img')?.getAttribute('src')).toBe(
diff --git a/apps/map-kiosk/project.json b/apps/map-kiosk/project.json
index 5e41ee3c8ce..74215ea63f1 100644
--- a/apps/map-kiosk/project.json
+++ b/apps/map-kiosk/project.json
@@ -123,7 +123,8 @@
"tsConfig": "apps/map-kiosk/tsconfig.spec.json",
"setupFiles": [
"libs/common/src/test-storage.ts",
- "apps/map-kiosk/src/test-setup.ts"
+ "shared/test-setup-base.ts",
+ "shared/test-setup.ts"
],
"isolate": true,
"watch": false,
diff --git a/apps/map-kiosk/src/app/app.config.ts b/apps/map-kiosk/src/app/app.config.ts
index 238011f9911..d371d1a1f29 100644
--- a/apps/map-kiosk/src/app/app.config.ts
+++ b/apps/map-kiosk/src/app/app.config.ts
@@ -3,17 +3,28 @@ import {
ErrorHandler,
provideZonelessChangeDetection,
} from '@angular/core';
-import { provideRouter, withHashLocation } from '@angular/router';
+import {
+ provideRouter,
+ withHashLocation,
+ withNavigationErrorHandler,
+} from '@angular/router';
import { provideServiceWorker } from '@angular/service-worker';
-import { LazySentryErrorHandler } from '@placeos/common';
+import {
+ LazySentryErrorHandler,
+ reloadOnChunkLoadError,
+} from '@placeos/common';
import { environment } from '../environments/environment';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(),
- provideRouter(routes, withHashLocation()),
+ provideRouter(
+ routes,
+ withHashLocation(),
+ withNavigationErrorHandler((e) => reloadOnChunkLoadError(e.error)),
+ ),
provideServiceWorker('ngsw-worker.js', {
enabled: environment.production,
}),
diff --git a/apps/map-kiosk/src/test-setup.ts b/apps/map-kiosk/src/test-setup.ts
deleted file mode 100644
index cfd12bbcfff..00000000000
--- a/apps/map-kiosk/src/test-setup.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { installTestStorage } from 'libs/common/src/test-storage';
-import { defineGlobalsInjections, Spectator } from '@ngneat/spectator';
-import { setNotifyOutlet } from '@placeos/common';
-import { TranslatePipe } from '@placeos/components';
-import { MockPipe } from 'ng-mocks';
-import { TextDecoder, TextEncoder } from 'util';
-
-global.TextEncoder = TextEncoder;
-global.TextDecoder = TextDecoder;
-installTestStorage();
-
-// Under the zoneless unit-test builder, `fixture.detectChanges()` only
-// refreshes views that were explicitly marked dirty. Plain host-property
-// changes (`setHostInput`), overridden getters and reassigned signals do not
-// mark the root view, so the main CD pass skips them while dev-mode
-// `checkNoChanges` still catches the new value -> NG0100. Emulate zone.js's
-// full dirty-check by marking the fixture root for check before every
-// `detectChanges()`.
-const _dom_spectator_proto: any = Object.getPrototypeOf(Spectator.prototype);
-const _original_detect_changes = _dom_spectator_proto.detectChanges;
-_dom_spectator_proto.detectChanges = function () {
- try {
- this.fixture?.componentRef?.changeDetectorRef?.markForCheck();
- } catch {
- // ignore - fixture may not expose a componentRef
- }
- return _original_detect_changes.apply(this, arguments);
-};
-
-setNotifyOutlet(null, true);
-
-defineGlobalsInjections({
- declarations: [MockPipe(TranslatePipe)],
-});
diff --git a/apps/map-kiosk/tsconfig.spec.json b/apps/map-kiosk/tsconfig.spec.json
index 94be5ce7ec1..65191cc4526 100644
--- a/apps/map-kiosk/tsconfig.spec.json
+++ b/apps/map-kiosk/tsconfig.spec.json
@@ -7,7 +7,7 @@
"moduleResolution": "bundler",
"isolatedModules": true
},
- "files": ["src/test-setup.ts"],
+ "files": ["../../shared/test-setup-base.ts", "../../shared/test-setup.ts"],
"include": ["**/*.spec.ts", "**/*.d.ts"],
"angularCompilerOptions": {
"extendedDiagnostics": {
diff --git a/apps/map-studio/SRS.md b/apps/map-studio/SRS.md
deleted file mode 100644
index e116efa8690..00000000000
--- a/apps/map-studio/SRS.md
+++ /dev/null
@@ -1,47 +0,0 @@
-# PlaceOS Map Studio - Software Requirements Specification
-
-## 1. Introduction
-
-### 1.1 Purpose
-
-To easily allow 3rd parties to create and edit maps for use with PlaceOS.
-
-### 1.2 Scope
-
-### 1.3 Definition, acronyms, and abbreviations
-
-### 1.4 References
-
-### 1.5 Overview
-
-#### Standard
-
-In line with international and systematic standards and recommendations guidelines, this SRS will follow IEEE/ANSI 830-1998, the definitive and most widely known SRS standard (Sommerville 2008) which is detailed in the following document, documents which further explicate this standard includes: Generating Software Requirements Specification (IEEE Std. 830 1998) document with Use Cases. Section 3 - Specific Requirements of the SRS can be organised in a number of ways as detailed in the Annex, this SRS will follow the A.5 Template of SRS Section 3: organized by feature. More information can be found here.
-
-## 2. Overall Description
-
-### 2.1 Product perspective
-
-### 2.2 Software interfaces
-
-### 2.3 Application Architecture
-
-### 2.4 Product functions
-
-### 2.5 User characteristics
-
-None, should be able to be used by anyone with the URL.
-
-### 2.6 Constraints
-
-### Assumptions and dependencies
-
-## 3. System Features and Requirements
-
-### 3.1 Functional Requirements
-
-### 3.2 External Interface Requirements
-
-### 3.3 System Features
-
-### 3.4 Nonfunctional Requirements
diff --git a/apps/map-studio/src/app/app.config.ts b/apps/map-studio/src/app/app.config.ts
index 3524f97b8d1..7d5e1c8627b 100644
--- a/apps/map-studio/src/app/app.config.ts
+++ b/apps/map-studio/src/app/app.config.ts
@@ -4,9 +4,14 @@ import {
provideAppInitializer,
provideZonelessChangeDetection,
} from '@angular/core';
-import { provideRouter, withHashLocation } from '@angular/router';
+import {
+ provideRouter,
+ withHashLocation,
+ withNavigationErrorHandler,
+} from '@angular/router';
import { provideServiceWorker } from '@angular/service-worker';
+import { reloadOnChunkLoadError } from '@placeos/common';
import { environment } from '../environments/environment';
import { routes } from './app.routes';
import { PlaceOSService } from './data/placeos.service';
@@ -30,7 +35,11 @@ export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(),
provideAppInitializer(initialisePlaceOS),
- provideRouter(routes, withHashLocation()),
+ provideRouter(
+ routes,
+ withHashLocation(),
+ withNavigationErrorHandler((e) => reloadOnChunkLoadError(e.error)),
+ ),
provideServiceWorker('ngsw-worker.js', {
enabled: environment.production,
}),
diff --git a/apps/map-studio/src/app/data/floorplan-ai.service.ts b/apps/map-studio/src/app/data/floorplan-ai.service.ts
index ef7b124c030..91aa3fb93e6 100644
--- a/apps/map-studio/src/app/data/floorplan-ai.service.ts
+++ b/apps/map-studio/src/app/data/floorplan-ai.service.ts
@@ -1,5 +1,6 @@
import { Injectable, inject } from '@angular/core';
+import { loadImage } from './load-image';
import { PlaceOSService } from './placeos.service';
import { StoreService } from './store.service';
@@ -142,22 +143,6 @@ async function prepareImage(source: Blob): Promise<{
return { base64: await blobBase64(jpeg), width, height };
}
-function loadImage(source: Blob): Promise {
- const url = URL.createObjectURL(source);
- return new Promise((resolve, reject) => {
- const image = new Image();
- image.onload = () => {
- URL.revokeObjectURL(url);
- resolve(image);
- };
- image.onerror = () => {
- URL.revokeObjectURL(url);
- reject(new Error('Could not read source image'));
- };
- image.src = url;
- });
-}
-
function drawCoordinateGrid(
context: CanvasRenderingContext2D,
width: number,
diff --git a/apps/map-studio/src/app/data/load-image.ts b/apps/map-studio/src/app/data/load-image.ts
new file mode 100644
index 00000000000..aa074f9e1db
--- /dev/null
+++ b/apps/map-studio/src/app/data/load-image.ts
@@ -0,0 +1,23 @@
+/**
+ * Loads an image from a URL or a blob. A blob's object URL is revoked once
+ * the image loads or fails. Rejects when the image cannot be read.
+ */
+export function loadImage(source: string | Blob): Promise {
+ const url =
+ typeof source === 'string' ? source : URL.createObjectURL(source);
+ const release = () => {
+ if (url !== source) URL.revokeObjectURL(url);
+ };
+ return new Promise((resolve, reject) => {
+ const image = new Image();
+ image.onload = () => {
+ release();
+ resolve(image);
+ };
+ image.onerror = () => {
+ release();
+ reject(new Error('Could not read source image'));
+ };
+ image.src = url;
+ });
+}
diff --git a/apps/map-studio/src/app/data/placeos.service.ts b/apps/map-studio/src/app/data/placeos.service.ts
index 90ea9995bb2..f69b55514c0 100644
--- a/apps/map-studio/src/app/data/placeos.service.ts
+++ b/apps/map-studio/src/app/data/placeos.service.ts
@@ -1,5 +1,5 @@
import { Injectable, signal } from '@angular/core';
-import { requestTokenFromOpener } from '@placeos/common';
+import { requestTokenFromOpener, withTimeout } from '@placeos/common';
import {
authorise,
authority,
@@ -17,27 +17,26 @@ const SETTINGS_KEY = 'MAP_BUILDER.placeos';
const FLOORPLAN_METADATA_KEY = 'map-studio';
const PLACEOS_REQUEST_TIMEOUT = 10 * 1000;
const HANDOFF_MIN_DELAY_MS = 30 * 1000;
-/** Zones per request, and the most pages read for one query */
-const ZONE_PAGE_SIZE = 100;
-const ZONE_PAGE_LIMIT = 50;
-
-function withDeadline(promise: Promise, message: string): Promise {
- return new Promise((resolve, reject) => {
- const timer = setTimeout(
- () => reject(new Error(message)),
- PLACEOS_REQUEST_TIMEOUT,
- );
- promise.then(
- (value) => {
- clearTimeout(timer);
- resolve(value);
- },
- (error) => {
- clearTimeout(timer);
- reject(error);
- },
- );
- });
+/** Items per request, and the most pages read for one list query */
+const PAGE_SIZE = 100;
+const PAGE_LIMIT = 50;
+
+/**
+ * Copy of `floorplan` without the fields that only this browser keeps: IDs,
+ * the PlaceOS level link, the source file details and the creation time.
+ * Used for floorplan metadata sent to or read from PlaceOS.
+ */
+export function withoutLocalFields(
+ floorplan: Partial = {},
+): Partial {
+ const data = { ...floorplan };
+ delete data.id;
+ delete data.project_id;
+ delete data.level_zone_id;
+ delete data.source_image_path;
+ delete data.source_type;
+ delete data.created_at;
+ return data;
}
function fetchWithDeadline(
@@ -49,9 +48,11 @@ function fetchWithDeadline(
const request = fetch(input, { ...init, signal: controller.signal }).then(
read,
);
- return withDeadline(request, 'PlaceOS request timed out.').finally(() =>
- controller.abort(),
- );
+ return withTimeout(
+ request,
+ PLACEOS_REQUEST_TIMEOUT,
+ 'PlaceOS request timed out.',
+ ).finally(() => controller.abort());
}
export interface PlaceOSSettings {
@@ -211,7 +212,11 @@ export class PlaceOSService {
handle_login: false,
};
try {
- await withDeadline(setup(options), 'PlaceOS setup timed out.');
+ await withTimeout(
+ setup(options),
+ PLACEOS_REQUEST_TIMEOUT,
+ 'PlaceOS setup timed out.',
+ );
if (!token()) await this._useOpenerToken();
options.handle_login = true;
// With handle_login on, this redirects to the login page
@@ -318,22 +323,11 @@ export class PlaceOSService {
// ── Zones ───────────────────────────────────────────────────────────────
/** Every matching zone, reading one page after another */
- public async getZones(
- tags?: string,
- parent_id?: string,
- ): Promise {
- const zones: PlaceOSZone[] = [];
- for (let page = 0; page < ZONE_PAGE_LIMIT; page++) {
- const results = await this._request('/zones', {
- limit: `${ZONE_PAGE_SIZE}`,
- offset: `${page * ZONE_PAGE_SIZE}`,
- ...(tags ? { tags } : {}),
- ...(parent_id ? { parent_id } : {}),
- });
- zones.push(...results);
- if (results.length < ZONE_PAGE_SIZE) break;
- }
- return zones;
+ public getZones(tags?: string, parent_id?: string): Promise {
+ return this._requestAll('/zones', {
+ ...(tags ? { tags } : {}),
+ ...(parent_id ? { parent_id } : {}),
+ });
}
public async getActiveOrganisation(): Promise {
@@ -383,13 +377,7 @@ export class PlaceOSService {
level_id: string,
floorplan: Floorplan,
): Promise {
- const details: Partial = { ...floorplan };
- delete details.id;
- delete details.project_id;
- delete details.level_zone_id;
- delete details.source_image_path;
- delete details.source_type;
- delete details.created_at;
+ const details = withoutLocalFields(floorplan);
// Imports keep the same SVG, often megabytes, in both fields. Send it
// once; reimport only reads svg_output when there is no svg_source.
if (details.svg_output === details.svg_source)
@@ -406,10 +394,6 @@ export class PlaceOSService {
: details.updated_at;
}
- public async getLevelSvg(level_id: string): Promise {
- return (await this.getLevelMap(level_id))?.svg ?? null;
- }
-
/** The level's current `map_id` and its SVG; null when the level has no map. */
public async getLevelMap(
level_id: string,
@@ -451,8 +435,7 @@ export class PlaceOSService {
zone_id?: string,
bookable?: boolean,
): Promise {
- return this._request('/systems', {
- limit: '100',
+ return this._requestAll('/systems', {
...(zone_id ? { zone_id } : {}),
...(bookable === undefined ? {} : { bookable: `${bookable}` }),
});
@@ -562,6 +545,24 @@ export class PlaceOSService {
};
}
+ /** Reads every page of a list endpoint, up to {@link PAGE_LIMIT} pages */
+ private async _requestAll(
+ path: string,
+ params: Record,
+ ): Promise {
+ const items: T[] = [];
+ for (let page = 0; page < PAGE_LIMIT; page++) {
+ const results = await this._request(path, {
+ ...params,
+ limit: `${PAGE_SIZE}`,
+ offset: `${page * PAGE_SIZE}`,
+ });
+ items.push(...results);
+ if (results.length < PAGE_SIZE) break;
+ }
+ return items;
+ }
+
private async _request(
path: string,
params: Record = {},
diff --git a/apps/map-studio/src/app/data/store.service.ts b/apps/map-studio/src/app/data/store.service.ts
index 65aade9e602..0506c2c2e6b 100644
--- a/apps/map-studio/src/app/data/store.service.ts
+++ b/apps/map-studio/src/app/data/store.service.ts
@@ -1,8 +1,9 @@
import { inject, Injectable, signal } from '@angular/core';
+import { mapWithLimit } from '@placeos/common';
import { DbService } from './db';
import type { PlaceOSZone } from './placeos.service';
-import { PlaceOSService } from './placeos.service';
+import { PlaceOSService, withoutLocalFields } from './placeos.service';
import { analyzeSvg, SvgAnalysis } from './svg-import';
import { makeThumbnail } from './thumbnail';
import {
@@ -10,11 +11,9 @@ import {
Floorplan,
FloorplanCanvas,
FloorplanSummary,
- FloorplanVersion,
MapObject,
MapObjectType,
Project,
- ValidationIssue,
} from './types';
type ZoneReference = Pick;
@@ -25,17 +24,6 @@ const SYNC_CONCURRENCY = 6;
/** Progress of the background import of PlaceOS buildings and levels */
export type PlaceOSSyncState = 'idle' | 'syncing' | 'done' | 'error';
-function localFloorplanData(metadata: Partial = {}) {
- const saved = { ...metadata };
- delete saved.id;
- delete saved.project_id;
- delete saved.level_zone_id;
- delete saved.source_image_path;
- delete saved.source_type;
- delete saved.created_at;
- return saved;
-}
-
const key = {
project: (id: string) => `project:${id}`,
floorplan: (id: string) => `floorplan:${id}`,
@@ -43,10 +31,6 @@ const key = {
object: (floorplan_id: string, id: string) =>
`object:${floorplan_id}:${id}`,
objects: (floorplan_id: string) => `object:${floorplan_id}:`,
- /** Zero-padded so a prefix scan comes back in version order */
- version: (floorplan_id: string, version: number) =>
- `version:${floorplan_id}:${`${version}`.padStart(8, '0')}`,
- versions: (floorplan_id: string) => `version:${floorplan_id}:`,
image: (floorplan_id: string) => `image:${floorplan_id}`,
thumbnail: (floorplan_id: string) => `thumbnail:${floorplan_id}`,
/** PlaceOS zone whose local project or floorplan the user deleted */
@@ -97,29 +81,6 @@ function serverSaveMessage(error: unknown): string {
}
const uuid = () => crypto.randomUUID();
-const CSV_COLUMNS = [
- 'id',
- 'floorplan_id',
- 'object_type',
- 'svg_id',
- 'label',
- 'geometry',
- 'entity_type',
- 'entity_id',
- 'layer',
- 'fill_color',
- 'stroke_color',
- 'opacity',
- 'capacity',
- 'amenities',
- 'tags',
- 'metadata',
- 'group_id',
- 'z_index',
- 'locked',
- 'visible',
-] as const;
-
/** Split a CSV line, honouring quoted fields containing commas or quotes */
export function parseCsvLine(line: string): string[] {
const fields: string[] = [];
@@ -151,7 +112,7 @@ const CSV_FORMULA_RE = /^[=+\-@\t\r']/;
/**
* One CSV cell. Labels can come from shared PlaceOS data, so text that a
- * spreadsheet would run gets a leading `'`, which `importObjectsCsv` removes.
+ * spreadsheet would run gets a leading `'`, which `fromCsvField` removes.
*/
export function toCsvField(value: unknown): string {
if (value === null || value === undefined) return '';
@@ -310,7 +271,7 @@ export class StoreService {
version: 1,
created_at: now(),
updated_at: now(),
- ...localFloorplanData(metadata),
+ ...withoutLocalFields(metadata),
};
await this._db.write({
put: [
@@ -382,26 +343,23 @@ export class StoreService {
building_lists.flat().map((zone) => [zone.id, zone]),
);
const zones = [...zones_by_id.values()];
- const buildings: {
- zone: ZoneReference;
- levels: ZoneReference[];
- }[] = [];
- // A few buildings at a time, so large deployments do not flood the API
- for (let i = 0; i < zones.length; i += SYNC_CONCURRENCY) {
- buildings.push(
- ...(await Promise.all(
- zones
- .slice(i, i + SYNC_CONCURRENCY)
- .map(async (zone) => ({
- zone,
- levels: await this._placeos.getZones(
- 'level',
- zone.id,
- ),
- })),
- )),
- );
- }
+ // A few buildings at a time, so large deployments do not flood
+ // the API. A failed level query fails the sync once the
+ // other queries end.
+ const failures: unknown[] = [];
+ const buildings = await mapWithLimit(
+ zones,
+ SYNC_CONCURRENCY,
+ (zone) =>
+ this._placeos.getZones('level', zone.id).then(
+ (levels) => ({ zone, levels }),
+ (error: unknown) => {
+ failures.push(error);
+ return { zone, levels: [] };
+ },
+ ),
+ );
+ if (failures.length) throw failures[0];
await this.syncPlaceOSZones(buildings);
}
this._placeos_sync.set('done');
@@ -441,28 +399,26 @@ export class StoreService {
([, level]) =>
!existing_levels.has(level.id) && !deleted.has(level.id),
);
- // Fetch metadata a few levels at a time rather than one by one
- for (let i = 0; i < missing.length; i += SYNC_CONCURRENCY) {
- const batch = missing.slice(i, i + SYNC_CONCURRENCY);
- const metadata = await Promise.all(
- batch.map(([, level]) =>
- this._placeos
- .getFloorplanMetadata(level.id)
- .catch(() => null),
- ),
+ // Fetch metadata a few levels at a time, then add the floors in order
+ const metadata = await mapWithLimit(
+ missing,
+ SYNC_CONCURRENCY,
+ ([, level]) =>
+ this._placeos
+ .getFloorplanMetadata(level.id)
+ .catch(() => null),
+ );
+ for (const [index, [floor_index, level]] of missing.entries()) {
+ if (existing_levels.has(level.id)) continue;
+ const floorplan = await this.addFloorplan(
+ project.id,
+ level.display_name || level.name || level.id,
+ floor_index,
+ level.id,
+ metadata[index] ?? undefined,
);
- for (const [index, [floor_index, level]] of batch.entries()) {
- if (existing_levels.has(level.id)) continue;
- const floorplan = await this.addFloorplan(
- project.id,
- level.display_name || level.name || level.id,
- floor_index,
- level.id,
- metadata[index] ?? undefined,
- );
- project.floorplans?.push(floorplan);
- existing_levels.add(level.id);
- }
+ project.floorplans?.push(floorplan);
+ existing_levels.add(level.id);
}
}
this.zone_sync_version.update((version) => version + 1);
@@ -506,7 +462,7 @@ export class StoreService {
const floorplan = await this.getFloorplan(id);
const updated = {
...floorplan,
- ...localFloorplanData(metadata),
+ ...withoutLocalFields(metadata),
id,
};
await this._db.write({
@@ -564,7 +520,7 @@ export class StoreService {
}
/**
- * Removes the floorplan, its objects, versions and image. A floorplan made
+ * Removes the floorplan, its objects and image. A floorplan made
* from a PlaceOS level is remembered so that the zone sync does not create
* it again.
*/
@@ -574,7 +530,6 @@ export class StoreService {
);
await Promise.all([
this._db.deleteWhere(key.objects(id)),
- this._db.deleteWhere(key.versions(id)),
this._db.write({
delete: [
key.image(id),
@@ -601,62 +556,6 @@ export class StoreService {
await this.updateFloorplan(id, { canvas_state });
}
- /** Snapshots the current canvas as a new version and marks it published */
- public async publishFloorplan(id: string): Promise {
- const floorplan = await this.getFloorplan(id);
- if (!floorplan.canvas_state)
- throw new Error('No canvas state to publish');
- const version = (floorplan.version ?? 1) + 1;
- await this._writeVersion(floorplan, version, 'Published');
- await this.updateFloorplan(id, { status: 'published', version });
- }
-
- public async listVersions(id: string): Promise {
- const versions = await this._db.list(
- key.versions(id),
- );
- return versions.reverse();
- }
-
- public async createVersion(id: string, note?: string): Promise {
- const floorplan = await this.getFloorplan(id);
- const version = (floorplan.version ?? 1) + 1;
- await this._writeVersion(floorplan, version, note ?? null);
- await this.updateFloorplan(id, { version });
- }
-
- public async restoreVersion(id: string, version_id: string): Promise {
- const version = (await this.listVersions(id)).find(
- (v) => v.id === version_id,
- );
- if (!version) throw new Error('Version not found');
- await this.updateFloorplan(id, {
- canvas_state: version.canvas_state,
- svg_source: version.svg_source ?? null,
- svg_source_ids: version.svg_source_ids,
- svg_output: version.svg_output,
- });
- }
-
- private async _writeVersion(
- floorplan: Floorplan,
- version: number,
- note: string | null,
- ): Promise {
- const snapshot: FloorplanVersion = {
- id: uuid(),
- floorplan_id: floorplan.id,
- version,
- canvas_state: floorplan.canvas_state as EditorState,
- svg_source: floorplan.svg_source,
- svg_source_ids: floorplan.svg_source_ids,
- svg_output: floorplan.svg_output,
- note,
- created_at: now(),
- };
- await this._db.set(key.version(floorplan.id, version), snapshot);
- }
-
// ── Source images ───────────────────────────────────────────────────────
/**
@@ -766,10 +665,8 @@ export class StoreService {
floor_name: string;
/** SVG ids to import, with the type and label the user settled on */
mappings: { svgId: string; objectType: string; label?: string }[];
- outline_points?: { x: number; y: number }[];
}): Promise<{ project_id: string; floorplan_id: string; objects: number }> {
- const { svg_content, analysis, floor_name, mappings, outline_points } =
- options;
+ const { svg_content, analysis, floor_name, mappings } = options;
if (!analysis.sourceSvg)
throw new Error('The file is not a valid SVG map.');
@@ -779,29 +676,6 @@ export class StoreService {
const source_ids = objects.flatMap((object) =>
object.svg_id ? [object.svg_id] : [],
);
- const has_outline = (outline_points?.length ?? 0) >= 3;
- if (has_outline) {
- const points = outline_points as { x: number; y: number }[];
- const xs = points.map((p) => p.x);
- const ys = points.map((p) => p.y);
- objects.push({
- object_type: 'area',
- svg_id: 'floor-outline',
- label: 'Floor Outline',
- geometry: {
- type: 'polygon',
- points,
- x: Math.min(...xs),
- y: Math.min(...ys),
- width: Math.max(...xs) - Math.min(...xs),
- height: Math.max(...ys) - Math.min(...ys),
- },
- layer: 'background',
- fill_color: 'rgba(107,114,128,0.15)',
- stroke_color: '#6b7280',
- z_index: 0,
- });
- }
const project_id =
options.project_id ??
@@ -838,26 +712,28 @@ export class StoreService {
for (const [index, parsed] of analysis.objects.entries()) {
const mapping = mappings?.get(parsed.svgId);
if (mappings && !mapping) continue;
+ const fill =
+ parsed.presentation?.fill || parsed.attributes['fill'] || null;
+ const stroke =
+ parsed.presentation?.stroke ||
+ parsed.attributes['stroke'] ||
+ null;
+ const opacity =
+ parsed.presentation?.opacity ??
+ Number(parsed.attributes['opacity'] ?? 1);
+ const label =
+ mapping?.label ||
+ parsed.label ||
+ (parsed.tag === 'text' ? '' : parsed.svgId);
objects.push({
object_type: (mapping?.objectType ??
parsed.suggestedType) as MapObjectType,
svg_id: parsed.svgId,
- label:
- mapping?.label ||
- parsed.label ||
- (parsed.tag === 'text' ? '' : parsed.svgId),
+ label,
geometry: parsed.geometry,
- fill_color:
- parsed.presentation?.fill ||
- parsed.attributes['fill'] ||
- null,
- stroke_color:
- parsed.presentation?.stroke ||
- parsed.attributes['stroke'] ||
- null,
- opacity:
- parsed.presentation?.opacity ??
- Number(parsed.attributes['opacity'] ?? 1),
+ fill_color: fill,
+ stroke_color: stroke,
+ opacity,
layer: parsed.layer,
group_id: parsed.groupId,
z_index: index,
@@ -866,21 +742,10 @@ export class StoreService {
originalTag: parsed.tag,
originalAttributes: parsed.attributes,
originalGeometry: parsed.geometry,
- originalFill:
- parsed.presentation?.fill ||
- parsed.attributes['fill'] ||
- null,
- originalStroke:
- parsed.presentation?.stroke ||
- parsed.attributes['stroke'] ||
- null,
- originalOpacity:
- parsed.presentation?.opacity ??
- Number(parsed.attributes['opacity'] ?? 1),
- originalLabel:
- mapping?.label ||
- parsed.label ||
- (parsed.tag === 'text' ? '' : parsed.svgId),
+ originalFill: fill,
+ originalStroke: stroke,
+ originalOpacity: opacity,
+ originalLabel: label,
parentMatrix: parsed.parentMatrix,
},
});
@@ -946,26 +811,6 @@ export class StoreService {
});
}
- public async updateObject(
- floorplan_id: string,
- id: string,
- data: Partial,
- ): Promise {
- const object = await this._db.get(
- key.object(floorplan_id, id),
- );
- if (!object) throw new Error('Object not found');
- await this._db.set(key.object(floorplan_id, id), {
- ...object,
- ...data,
- id,
- });
- }
-
- public async deleteObject(floorplan_id: string, id: string): Promise {
- await this._db.delete(key.object(floorplan_id, id));
- }
-
/**
* Writes `objects` and removes `remove_ids` in one transaction. Returns the
* objects as stored, with defaults filled in.
@@ -982,122 +827,4 @@ export class StoreService {
});
return records.map(([, object]) => object);
}
-
- /** Replaces the stored objects for a floorplan with exactly `objects` */
- public async replaceObjects(
- floorplan_id: string,
- objects: Partial[],
- ): Promise {
- await this._db.write({
- clear: key.objects(floorplan_id),
- put: this._objectRecords(floorplan_id, objects),
- });
- }
-
- // ── CSV ─────────────────────────────────────────────────────────────────
-
- public async exportObjectsCsv(floorplan_id: string): Promise {
- const objects = await this.listObjects(floorplan_id);
- const rows = objects.map((obj) =>
- CSV_COLUMNS.map((column) =>
- toCsvField((obj as unknown as Record)[column]),
- ).join(','),
- );
- return [CSV_COLUMNS.join(','), ...rows].join('\n');
- }
-
- /**
- * Merges a CSV export back in. Rows matching an existing `svg_id` update
- * that object; everything else is created.
- */
- public async importObjectsCsv(
- floorplan_id: string,
- csv: string,
- ): Promise<{ imported: number; updated: number }> {
- const lines = csv.trim().split('\n');
- if (lines.length < 2)
- throw new Error(
- 'CSV must have a header row and at least one data row',
- );
-
- const headers = parseCsvLine(lines[0]);
- const existing = new Map(
- (await this.listObjects(floorplan_id))
- .filter((o) => o.svg_id)
- .map((o) => [o.svg_id as string, o]),
- );
-
- let imported = 0;
- let updated = 0;
- const changed: Partial[] = [];
- for (const line of lines.slice(1)) {
- const values = parseCsvLine(line);
- const row: Record = {};
- headers.forEach((header, index) => {
- const value = fromCsvField(values[index] ?? '');
- if (value !== '') row[header] = value;
- });
- if (row['geometry'])
- row['geometry'] = JSON.parse(row['geometry'] as string);
-
- const match = existing.get(row['svg_id'] as string);
- if (match) {
- delete row['id'];
- delete row['floorplan_id'];
- const merged = { ...match, ...row, id: match.id };
- // A later row for the same svg_id builds on this one
- existing.set(match.svg_id as string, merged);
- changed.push(merged);
- updated++;
- } else {
- changed.push({ ...row, floorplan_id } as Partial);
- imported++;
- }
- }
- await this.bulkUpsertObjects(floorplan_id, changed);
- return { imported, updated };
- }
-
- // ── Validation ──────────────────────────────────────────────────────────
-
- public async validateFloorplan(
- floorplan_id: string,
- ): Promise {
- const objects = await this.listObjects(floorplan_id);
- const issues: ValidationIssue[] = [];
- const seen = new Set();
- const duplicates = new Set();
-
- for (const obj of objects) {
- if (!obj.label) {
- issues.push({
- type: 'warning',
- objectId: obj.id,
- message: `Object ${obj.id} (${obj.object_type}) has no label`,
- });
- }
- if (obj.svg_id) {
- if (seen.has(obj.svg_id)) duplicates.add(obj.svg_id);
- seen.add(obj.svg_id);
- }
- if (!obj.geometry || Object.keys(obj.geometry).length === 0) {
- issues.push({
- type: 'error',
- objectId: obj.id,
- message: `Object ${obj.id} (${obj.object_type}) has empty geometry`,
- });
- }
- }
-
- for (const svg_id of duplicates) {
- for (const obj of objects.filter((o) => o.svg_id === svg_id)) {
- issues.push({
- type: 'error',
- objectId: obj.id,
- message: `Duplicate svg_id "${svg_id}" on object ${obj.id}`,
- });
- }
- }
- return issues;
- }
}
diff --git a/apps/map-studio/src/app/data/svg-import.ts b/apps/map-studio/src/app/data/svg-import.ts
index 0f20feed93d..56c21b0e7bc 100644
--- a/apps/map-studio/src/app/data/svg-import.ts
+++ b/apps/map-studio/src/app/data/svg-import.ts
@@ -1,7 +1,9 @@
import {
invert,
multiply,
+ pointsIn,
sanitiseTree,
+ transformBBox,
translation,
} from '@placeos/map-editor';
import { MapObjectType, ObjectGeometry } from './types';
@@ -137,17 +139,6 @@ function parseNumber(value: string | null): number | undefined {
return isNaN(num) ? undefined : num;
}
-function parsePoints(points: string): { x: number; y: number }[] {
- const parts = points.trim().split(/[\s,]+/);
- const parsed: { x: number; y: number }[] = [];
- for (let i = 0; i < parts.length - 1; i += 2) {
- const x = parseFloat(parts[i]);
- const y = parseFloat(parts[i + 1]);
- if (!isNaN(x) && !isNaN(y)) parsed.push({ x, y });
- }
- return parsed;
-}
-
function extractGeometry(el: Element): ObjectGeometry {
switch (el.tagName.toLowerCase()) {
case 'rect':
@@ -181,7 +172,7 @@ function extractGeometry(el: Element): ObjectGeometry {
const points = el.getAttribute('points');
return {
type: 'polygon',
- points: points ? parsePoints(points) : [],
+ points: pointsIn(points).map(([x, y]) => ({ x, y })),
};
}
case 'path':
@@ -379,25 +370,13 @@ export function analyzeSvg(svg_content: string): SvgAnalysis {
f: pm.f,
};
if (matrix && (box.width || box.height)) {
- const corners = [
- [box.x, box.y],
- [box.x + box.width, box.y],
- [box.x, box.y + box.height],
- [box.x + box.width, box.y + box.height],
- ];
- const xs = corners.map(
- ([x, y]) => matrix.a * x + matrix.c * y + matrix.e,
- );
- const ys = corners.map(
- ([x, y]) => matrix.b * x + matrix.d * y + matrix.f,
- );
- geometry = {
- type: 'rect',
- x: Math.min(...xs),
- y: Math.min(...ys),
- width: Math.max(...xs) - Math.min(...xs),
- height: Math.max(...ys) - Math.min(...ys),
- };
+ const [x, y, width, height] = transformBBox(matrix, [
+ box.x,
+ box.y,
+ box.width,
+ box.height,
+ ]);
+ geometry = { type: 'rect', x, y, width, height };
}
} catch {
/* Non-rendering SVG nodes have no bounding box. */
diff --git a/apps/map-studio/src/app/data/thumbnail.ts b/apps/map-studio/src/app/data/thumbnail.ts
index d950c06e652..884985a8c78 100644
--- a/apps/map-studio/src/app/data/thumbnail.ts
+++ b/apps/map-studio/src/app/data/thumbnail.ts
@@ -1,3 +1,5 @@
+import { loadImage } from './load-image';
+
/** Longest edge of a stored preview, in pixels */
const THUMBNAIL_SIZE = 480;
@@ -7,11 +9,8 @@ const THUMBNAIL_SIZE = 480;
* to the original image if it cannot be drawn.
*/
export async function makeThumbnail(image: Blob): Promise {
- const url = URL.createObjectURL(image);
try {
- const element = new Image();
- element.src = url;
- await element.decode();
+ const element = await loadImage(image);
const width = element.naturalWidth;
const height = element.naturalHeight;
if (!width || !height) return image;
@@ -28,7 +27,5 @@ export async function makeThumbnail(image: Blob): Promise {
return thumbnail ?? image;
} catch {
return image;
- } finally {
- URL.revokeObjectURL(url);
}
}
diff --git a/apps/map-studio/src/app/data/types.ts b/apps/map-studio/src/app/data/types.ts
index d8614b4020b..1fb8e98d451 100644
--- a/apps/map-studio/src/app/data/types.ts
+++ b/apps/map-studio/src/app/data/types.ts
@@ -46,18 +46,6 @@ export type FloorplanCanvas = Pick<
/** Floorplan without its canvas, as listings load it */
export type FloorplanSummary = Omit;
-export interface FloorplanVersion {
- id: string;
- floorplan_id: string;
- version: number;
- canvas_state: EditorState;
- svg_source?: string | null;
- svg_source_ids?: string[];
- svg_output: string | null;
- note: string | null;
- created_at: string;
-}
-
export type MapObjectType =
| 'room'
| 'desk'
diff --git a/apps/map-studio/src/app/editor/availability.ts b/apps/map-studio/src/app/editor/availability.ts
index 34957e8ca0a..215f4342650 100644
--- a/apps/map-studio/src/app/editor/availability.ts
+++ b/apps/map-studio/src/app/editor/availability.ts
@@ -1,4 +1,20 @@
-import { AvailabilityState } from '../data/types';
+import { AvailabilityState, MapObject } from '../data/types';
+
+/** Whether PlaceOS can bind a booking to the object: rooms and desks */
+export const isBookable = (object: Pick) =>
+ object.object_type === 'room' || object.object_type === 'desk';
+
+/**
+ * Converts a `#rrggbb` colour into rgba() at the given opacity. A missing `#`
+ * is allowed, and a part that is not hex reads as 0.
+ */
+export function toFillOpacity(hex: string, opacity: number): string {
+ const clean = hex.replace('#', '').slice(0, 6);
+ const r = parseInt(clean.slice(0, 2), 16) || 0;
+ const g = parseInt(clean.slice(2, 4), 16) || 0;
+ const b = parseInt(clean.slice(4, 6), 16) || 0;
+ return `rgba(${r},${g},${b},${opacity})`;
+}
/** Colour each availability state paints objects in preview mode */
export const STATE_COLORS: Record = {
diff --git a/apps/map-studio/src/app/editor/canvas.component.ts b/apps/map-studio/src/app/editor/canvas.component.ts
index b49835e4e0a..77a52eab0df 100644
--- a/apps/map-studio/src/app/editor/canvas.component.ts
+++ b/apps/map-studio/src/app/editor/canvas.component.ts
@@ -12,7 +12,7 @@ import {
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { MapObject } from '../data/types';
-import { getAvailabilityColor } from './availability';
+import { getAvailabilityColor, isBookable } from './availability';
import {
AMENITY_ICONS,
FURNITURE_ASSETS,
@@ -30,12 +30,22 @@ import {
closestPointOnSegment,
computeSnap,
getHandlePos,
+ objectBounds,
pointInRotatedRect,
rotatePoint,
} from './geometry';
import { composeSourceSvg, importedObject } from './source-svg';
+import { sequentialId } from './svg-export';
import { ToastService } from './toast.service';
+/** Position of each resize handle on the given bounding box */
+function handlePositions(x: number, y: number, w: number, h: number) {
+ return HANDLES.map((handle) => {
+ const [hx, hy] = getHandlePos(handle, x, y, w, h);
+ return { handle, x: hx, y: hy };
+ });
+}
+
interface DragState {
id: string;
offsetX: number;
@@ -310,17 +320,17 @@ const clamp01 = (value: number) => Math.max(0, Math.min(1, value));
object.id === state.selected_id() &&
state.multi_select().length < 2
) {
- @for (handle of handles; track handle) {
+ @for (pos of selected_handles(); track pos.handle) {
}
Math.max(0, Math.min(1, value));
stroke-dasharray="6 3"
class="pointer-events-none"
/>
- @for (handle of handles; track handle) {
+ @for (pos of group_handles(); track pos.handle) {
}
Math.max(0, Math.min(1, value));
state.floorplan()?.svg_source &&
!object.locked
) {
- @for (handle of handles; track handle) {
+ @for (pos of selected_handles(); track pos.handle) {
}
>('renameInput');
- public readonly handles = HANDLES;
-
public readonly rect_draw = signal(null);
public readonly selection_box = signal(null);
public readonly outline_points = signal([]);
@@ -849,8 +857,7 @@ export class CanvasComponent {
/** Move cursor over objects the select tool can drag, else inherit */
public moveCursor(object: MapObject) {
const cycles =
- this.state.availability_enabled() &&
- (object.object_type === 'room' || object.object_type === 'desk');
+ this.state.availability_enabled() && isBookable(object);
return this._can_move() &&
!cycles &&
!object.locked &&
@@ -970,6 +977,25 @@ export class CanvasComponent {
};
});
+ /** Resize handle positions around the selected object */
+ public readonly selected_handles = computed(() => {
+ const geometry = this.state.selected()?.geometry;
+ if (!geometry) return [];
+ return handlePositions(
+ geometry.x ?? 0,
+ geometry.y ?? 0,
+ geometry.width ?? 50,
+ geometry.height ?? 50,
+ );
+ });
+
+ /** Resize handle positions around the multi-selection */
+ public readonly group_handles = computed(() => {
+ const group = this.groupBounds();
+ if (!group) return [];
+ return handlePositions(group.x, group.y, group.width, group.height);
+ });
+
public readonly groupBounds = computed(() => {
const ids = this.state.multi_select_ids();
if (ids.size < 2) return null;
@@ -977,7 +1003,7 @@ export class CanvasComponent {
.objects()
.filter((object) => ids.has(object.id));
if (objects.length < 2) return null;
- const bounds = objects.map((object) => this._objectBounds(object));
+ const bounds = objects.map((object) => objectBounds(object.geometry));
const x = Math.min(...bounds.map((item) => item.x));
const y = Math.min(...bounds.map((item) => item.y));
const right = Math.max(...bounds.map((item) => item.x + item.width));
@@ -1098,50 +1124,10 @@ export class CanvasComponent {
return (object.geometry.y ?? 0) - 25 / (this.state.stroke_width() / 2);
}
- public handleX(object: MapObject, handle: Handle) {
- return getHandlePos(
- handle,
- object.geometry.x ?? 0,
- object.geometry.y ?? 0,
- object.geometry.width ?? 50,
- object.geometry.height ?? 50,
- )[0];
- }
-
- public handleY(object: MapObject, handle: Handle) {
- return getHandlePos(
- handle,
- object.geometry.x ?? 0,
- object.geometry.y ?? 0,
- object.geometry.width ?? 50,
- object.geometry.height ?? 50,
- )[1];
- }
-
public cursorFor(handle: Handle) {
return HANDLE_CURSORS[handle];
}
- public groupHandleX(group: GroupTransformState['bounds'], handle: Handle) {
- return getHandlePos(
- handle,
- group.x,
- group.y,
- group.width,
- group.height,
- )[0];
- }
-
- public groupHandleY(group: GroupTransformState['bounds'], handle: Handle) {
- return getHandlePos(
- handle,
- group.x,
- group.y,
- group.width,
- group.height,
- )[1];
- }
-
public groupRotateY(group: GroupTransformState['bounds']) {
return group.y - 25 / (this.state.stroke_width() / 2);
}
@@ -1207,8 +1193,7 @@ export class CanvasComponent {
public isPolygonEditable(object: MapObject) {
return (
object.id === this.state.selected_id() &&
- this.state.active_tool() === 'select' &&
- this.state.mode() === 'design'
+ this.state.active_tool() === 'select'
);
}
@@ -1450,10 +1435,7 @@ export class CanvasComponent {
const hit = this._hitTest(x, y);
if (hit) {
// In preview mode a click cycles the availability state instead
- if (
- this.state.availability_enabled() &&
- (hit.object_type === 'room' || hit.object_type === 'desk')
- ) {
+ if (this.state.availability_enabled() && isBookable(hit)) {
this.state.select(hit.id);
this.state.cycleAvailability(hit);
event.preventDefault();
@@ -1756,12 +1738,16 @@ export class CanvasComponent {
this._startPress(event);
const centerX = this.centerX(object);
const centerY = this.centerY(object);
- const handleY = this.rotateHandleY(object);
+ // Measure from the pointer, not the handle: a handle inside a rotated
+ // shape turns with it, so it is not always straight above the center
+ const pointer = this._toCanvas(event.clientX, event.clientY);
this._rotating.set({
id: object.id,
centerX,
centerY,
- startAngle: (Math.atan2(handleY - centerY, 0) * 180) / Math.PI,
+ startAngle:
+ (Math.atan2(pointer.y - centerY, pointer.x - centerX) * 180) /
+ Math.PI,
startRotation: object.geometry.rotation ?? 0,
});
}
@@ -1992,7 +1978,7 @@ export class CanvasComponent {
geometry.points = geometry.points.map(transform);
Object.assign(geometry, boundsOf(geometry.points));
} else {
- const bounds = this._objectBounds(object);
+ const bounds = objectBounds(object.geometry);
const center = transform({
x: bounds.x + bounds.width / 2,
y: bounds.y + bounds.height / 2,
@@ -2050,7 +2036,7 @@ export class CanvasComponent {
.visible_objects()
.filter((object) => {
if (object.layer !== activeLayer || object.locked) return false;
- const bounds = this._objectBounds(object);
+ const bounds = objectBounds(object.geometry);
return (
bounds.x >= left &&
bounds.y >= top &&
@@ -2070,26 +2056,6 @@ export class CanvasComponent {
.map((object) => structuredClone(object));
}
- private _objectBounds(object: MapObject) {
- const geometry = object.geometry;
- if (geometry.points?.length) return boundsOf(geometry.points);
- if (geometry.type === 'circle') {
- const r = geometry.r ?? 12;
- return {
- x: (geometry.x ?? 0) - r,
- y: (geometry.y ?? 0) - r,
- width: r * 2,
- height: r * 2,
- };
- }
- return {
- x: geometry.x ?? 0,
- y: geometry.y ?? 0,
- width: geometry.width ?? 0,
- height: geometry.height ?? 0,
- };
- }
-
private _applyVertexDrag(
vertex: { id: string; idx: number },
x: number,
@@ -2146,12 +2112,7 @@ export class CanvasComponent {
/** Topmost selectable object under the cursor, preferring the active layer */
private _hitTest(x: number, y: number): MapObject | null {
- const selectable = new Set(
- this.state
- .layers()
- .filter((l) => l.visible && !l.locked)
- .map((l) => l.id),
- );
+ const selectable = this._pickable_layers();
const active = this.state.active_layer_id();
const hits: { object: MapObject; area: number; onActive: boolean }[] =
[];
@@ -2327,7 +2288,7 @@ export class CanvasComponent {
{
object_type: 'decorative',
label: `Wall ${count + 1}`,
- svg_id: `wall-${String(count + 1).padStart(3, '0')}`,
+ svg_id: sequentialId('wall-', count + 1),
geometry: { type: 'polygon', points, ...boundsOf(points) },
layer: 'walls',
fill_color: '#374151',
diff --git a/apps/map-studio/src/app/editor/constants.ts b/apps/map-studio/src/app/editor/constants.ts
index e41e360cfc8..91d8a94588d 100644
--- a/apps/map-studio/src/app/editor/constants.ts
+++ b/apps/map-studio/src/app/editor/constants.ts
@@ -3,11 +3,10 @@ import { MapObjectType } from '../data/types';
/**
* Editor constants and artwork, ported from the reference application.
*
- * The icon and furniture tables are verbatim — they are data, not logic.
+ * The icon and furniture artwork is verbatim. It is data, not logic.
*/
export type Tool = 'select' | 'rect' | 'polygon' | 'pen' | 'wall';
-export type EditorMode = 'design' | 'label' | 'preview';
export type Handle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
export const HANDLES: Handle[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
@@ -37,6 +36,21 @@ export const TYPE_COLORS: Record = {
locker: '#9333ea',
};
+/**
+ * Type colours of the object list and the minimap. They are not the canvas
+ * colours in `TYPE_COLORS`.
+ */
+export const TYPE_ACCENT_COLORS: Record = {
+ room: '#3b82f6',
+ desk: '#22c55e',
+ zone: '#a855f7',
+ area: '#f59e0b',
+ amenity: '#06b6d4',
+ decorative: '#6b7280',
+ parking: '#8b5cf6',
+ locker: '#ec4899',
+};
+
export const OBJECT_TYPES: MapObjectType[] = [
'room',
'desk',
@@ -73,14 +87,12 @@ export function layerToObjectType(layer_id: string): MapObjectType {
export interface AmenityIconAsset {
id: string;
label: string;
- emoji: string;
svg: string;
}
export interface FurnitureAsset {
id: string;
label: string;
- icon: string;
svg: string;
w: number;
h: number;
@@ -101,151 +113,126 @@ export const AMENITY_ICONS: AmenityIconAsset[] = [
{
id: 'male-restroom',
label: 'Male Restroom',
- emoji: '🚹',
svg: '',
},
{
id: 'female-restroom',
label: 'Female Restroom',
- emoji: '🚺',
svg: '',
},
{
id: 'accessible-restroom',
label: 'Accessible',
- emoji: '♿',
svg: '',
},
{
id: 'staircase',
label: 'Staircase',
- emoji: '🪜',
svg: '',
},
{
id: 'elevator',
label: 'Elevator',
- emoji: '🛗',
svg: '',
},
{
id: 'fire-exit',
label: 'Fire Exit',
- emoji: '🚪',
svg: '',
},
{
id: 'cafe',
label: 'Cafe',
- emoji: '☕',
svg: '',
},
{
id: 'reception',
label: 'Reception',
- emoji: '🛎️',
svg: '',
},
{
id: 'aed',
label: 'AED',
- emoji: '💚',
svg: '',
},
{
id: 'first-aid',
label: 'First Aid',
- emoji: '🏥',
svg: '',
},
{
id: 'lockers',
label: 'Lockers',
- emoji: '🔐',
svg: '',
},
{
id: 'presentation',
label: 'Presentation',
- emoji: '📽️',
svg: '',
},
{
id: 'door',
label: 'Door',
- emoji: '🚪',
svg: '',
},
{
id: 'window',
label: 'Window',
- emoji: '🪟',
svg: '',
},
{
id: 'water-fountain',
label: 'Water Fountain',
- emoji: '🚰',
svg: '',
},
{
id: 'kitchen',
label: 'Kitchen',
- emoji: '🍳',
svg: '',
},
{
id: 'vending',
label: 'Vending Machine',
- emoji: '🥤',
svg: '',
},
{
id: 'wifi',
label: 'WiFi Access Point',
- emoji: '📶',
svg: '',
},
{
id: 'security-camera',
label: 'Security Camera',
- emoji: '📹',
svg: '',
},
{
id: 'parking-spot',
label: 'Parking Spot',
- emoji: '🅿️',
svg: '',
},
{
id: 'bike-rack',
label: 'Bike Rack',
- emoji: '🚲',
svg: '',
},
{
id: 'charging',
label: 'Charging Station',
- emoji: '🔌',
svg: '',
},
{
id: 'mail',
label: 'Mailroom',
- emoji: '📬',
svg: '',
},
{
id: 'shower',
label: 'Shower',
- emoji: '🚿',
svg: '',
},
{
id: 'smoking',
label: 'Smoking Area',
- emoji: '🚬',
svg: '',
},
];
@@ -255,7 +242,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'desk-single',
label: 'Single Desk',
- icon: 'D',
svg: '',
w: 30,
h: 20,
@@ -264,7 +250,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'desk-pair',
label: 'Desk Pair',
- icon: 'DD',
svg: '',
w: 50,
h: 20,
@@ -273,7 +258,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'desk-pod',
label: 'Desk Pod (4)',
- icon: '4D',
svg: '',
w: 50,
h: 40,
@@ -282,7 +266,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'table-small',
label: 'Small Table',
- icon: 'T',
svg: '',
w: 30,
h: 30,
@@ -291,7 +274,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'table-medium',
label: 'Medium Table',
- icon: 'T',
svg: '',
w: 50,
h: 30,
@@ -300,7 +282,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'table-large',
label: 'Large Table',
- icon: 'T',
svg: '',
w: 80,
h: 40,
@@ -309,7 +290,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'table-round',
label: 'Round Table',
- icon: 'O',
svg: '',
w: 30,
h: 30,
@@ -318,7 +298,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'standing-desk',
label: 'Standing Desk',
- icon: 'SD',
svg: '',
w: 30,
h: 15,
@@ -328,7 +307,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'bench',
label: 'Bench',
- icon: 'B',
svg: '',
w: 50,
h: 12,
@@ -337,7 +315,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'lounge-chair',
label: 'Lounge Chair',
- icon: 'LC',
svg: '',
w: 25,
h: 25,
@@ -346,7 +323,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'sofa',
label: 'Sofa',
- icon: 'So',
svg: '',
w: 55,
h: 22,
@@ -355,7 +331,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'phone-booth',
label: 'Phone Booth',
- icon: 'PB',
svg: '',
w: 25,
h: 25,
@@ -365,7 +340,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'lockers',
label: 'Lockers',
- icon: 'Lk',
svg: '',
w: 40,
h: 15,
@@ -374,7 +348,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'filing-cabinet',
label: 'Filing Cabinet',
- icon: 'FC',
svg: '',
w: 15,
h: 20,
@@ -383,7 +356,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'bookshelf',
label: 'Bookshelf',
- icon: 'BS',
svg: '',
w: 40,
h: 12,
@@ -392,7 +364,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'printer',
label: 'Printer',
- icon: 'Pr',
svg: '',
w: 20,
h: 20,
@@ -402,7 +373,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'plant',
label: 'Plant',
- icon: 'Pl',
svg: '',
w: 12,
h: 12,
@@ -411,7 +381,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'plant-large',
label: 'Large Plant',
- icon: 'PL',
svg: '',
w: 18,
h: 18,
@@ -420,7 +389,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'partition',
label: 'Partition Wall',
- icon: '||',
svg: '',
w: 60,
h: 4,
@@ -429,7 +397,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'whiteboard',
label: 'Whiteboard',
- icon: 'WB',
svg: '',
w: 40,
h: 5,
@@ -438,7 +405,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'tv-screen',
label: 'TV/Screen',
- icon: 'TV',
svg: '',
w: 35,
h: 5,
@@ -447,7 +413,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'bin',
label: 'Waste Bin',
- icon: 'Bn',
svg: '',
w: 8,
h: 8,
@@ -457,7 +422,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'conference-table',
label: 'Conference Table',
- icon: 'CT',
svg: '',
w: 80,
h: 50,
@@ -466,7 +430,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'monitor-arm',
label: 'Monitor Arm',
- icon: 'MA',
svg: '',
w: 15,
h: 10,
@@ -475,7 +438,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'chair-office',
label: 'Office Chair',
- icon: 'Ch',
svg: '',
w: 12,
h: 12,
@@ -484,7 +446,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'recycling',
label: 'Recycling Bin',
- icon: 'Rc',
svg: '',
w: 8,
h: 8,
@@ -493,7 +454,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'coat-rack',
label: 'Coat Rack',
- icon: 'CR',
svg: '',
w: 8,
h: 8,
@@ -502,7 +462,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'umbrella-stand',
label: 'Umbrella Stand',
- icon: 'US',
svg: '',
w: 8,
h: 8,
@@ -511,7 +470,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'water-cooler',
label: 'Water Cooler',
- icon: 'WC',
svg: '',
w: 10,
h: 10,
@@ -520,7 +478,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'fire-extinguisher',
label: 'Fire Extinguisher',
- icon: 'FE',
svg: '',
w: 6,
h: 6,
@@ -529,7 +486,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [
{
id: 'locker-unit',
label: 'Locker Unit',
- icon: 'LU',
svg: '',
w: 45,
h: 20,
diff --git a/apps/map-studio/src/app/editor/editor-state.service.ts b/apps/map-studio/src/app/editor/editor-state.service.ts
index bab5f19c2cf..0bb3ea8d884 100644
--- a/apps/map-studio/src/app/editor/editor-state.service.ts
+++ b/apps/map-studio/src/app/editor/editor-state.service.ts
@@ -1,8 +1,9 @@
import { Injectable, computed, inject, signal } from '@angular/core';
-import { AsyncHandler } from '@placeos/common';
+import { AsyncHandler, errorMessage } from '@placeos/common';
import type { DetectedRoom } from '../data/floorplan-ai.service';
+import { loadImage } from '../data/load-image';
import { PlaceOSService } from '../data/placeos.service';
import { StoreService } from '../data/store.service';
import {
@@ -13,11 +14,15 @@ import {
MapObject,
MapObjectType,
} from '../data/types';
-import { cycleState, defaultStateFor, getStatesForType } from './availability';
+import {
+ cycleState,
+ defaultStateFor,
+ getStatesForType,
+ isBookable,
+} from './availability';
import {
AMENITY_ICONS,
DESK_LAYOUTS,
- EditorMode,
FURNITURE_ASSETS,
TYPE_COLORS,
Tool,
@@ -146,14 +151,12 @@ export class EditorStateService extends AsyncHandler {
private readonly _selected_id = signal(null);
private readonly _active_tool = signal('select');
private readonly _active_layer_id = signal('rooms');
- private readonly _mode = signal('design');
private readonly _zoom = signal(1);
private readonly _grid_enabled = signal(true);
private readonly _snap_enabled = signal(true);
private readonly _grid_size = signal(20);
private readonly _dirty = signal(false);
private readonly _saving = signal(false);
- private readonly _last_saved = signal(null);
private readonly _loading = signal(true);
private readonly _error = signal('');
private readonly _project_name = signal('');
@@ -191,21 +194,18 @@ export class EditorStateService extends AsyncHandler {
public readonly selected_id = this._selected_id.asReadonly();
public readonly active_tool = this._active_tool.asReadonly();
public readonly active_layer_id = this._active_layer_id.asReadonly();
- public readonly mode = this._mode.asReadonly();
public readonly zoom = this._zoom.asReadonly();
public readonly grid_enabled = this._grid_enabled.asReadonly();
public readonly snap_enabled = this._snap_enabled.asReadonly();
public readonly grid_size = this._grid_size.asReadonly();
public readonly dirty = this._dirty.asReadonly();
public readonly saving = this._saving.asReadonly();
- public readonly last_saved = this._last_saved.asReadonly();
public readonly loading = this._loading.asReadonly();
public readonly error = this._error.asReadonly();
public readonly server_save_error =
this._store.server_save_error.asReadonly();
public readonly project_name = this._project_name.asReadonly();
public readonly image_url = this._image_url.asReadonly();
- public readonly image_dims = this._image_dims.asReadonly();
public readonly place_size = this._place_size.asReadonly();
public readonly can_undo = this._can_undo.asReadonly();
public readonly can_redo = this._can_redo.asReadonly();
@@ -315,7 +315,7 @@ export class EditorStateService extends AsyncHandler {
await this.load(this._floorplan_id);
await this.save();
} catch (error) {
- this._error.set(this._message(error, 'Failed to reimport SVG'));
+ this._error.set(errorMessage(error) || 'Failed to reimport SVG');
} finally {
this._loading.set(false);
}
@@ -455,7 +455,7 @@ export class EditorStateService extends AsyncHandler {
await this._loadImage(floorplan_id);
if (imported_svg) await this.save();
} catch (error) {
- this._error.set(this._message(error, 'Failed to load floorplan'));
+ this._error.set(errorMessage(error) || 'Failed to load floorplan');
} finally {
this._loading.set(false);
}
@@ -480,20 +480,37 @@ export class EditorStateService extends AsyncHandler {
this._image_dims.set(null);
return;
}
- const dims = await new Promise<{
- width: number;
- height: number;
- } | null>((resolve) => {
- const img = new Image();
- img.onload = () =>
- resolve({
- width: img.naturalWidth,
- height: img.naturalHeight,
- });
- img.onerror = () => resolve(null);
- img.src = url;
- });
+ const dims = await loadImage(url).then(
+ (img) => ({ width: img.naturalWidth, height: img.naturalHeight }),
+ () => null,
+ );
this._image_dims.set(dims);
+ await this._storeImageSize(floorplan_id, dims);
+ }
+
+ /**
+ * Saves a traced image's pixel size as the floor's canvas size. The kiosk
+ * reads only the stored size, so this keeps it in step with the editor.
+ * Floors with an SVG source already store the SVG's size.
+ */
+ private async _storeImageSize(
+ floorplan_id: string,
+ dims: { width: number; height: number } | null,
+ ) {
+ const floorplan = this._floorplan();
+ if (!dims || floorplan?.id !== floorplan_id || floorplan.svg_source)
+ return;
+ if (
+ floorplan.canvas_width === dims.width &&
+ floorplan.canvas_height === dims.height
+ )
+ return;
+ const updated = await this._store.updateFloorplan(floorplan_id, {
+ canvas_width: dims.width,
+ canvas_height: dims.height,
+ });
+ if (!this._destroyed && this._floorplan_id === floorplan_id)
+ this._floorplan.set(updated);
}
public async uploadSourceImage(file: File) {
@@ -504,7 +521,7 @@ export class EditorStateService extends AsyncHandler {
);
await this._loadImage(this._floorplan_id);
} catch (error) {
- this._error.set(this._message(error, 'Upload failed'));
+ this._error.set(errorMessage(error) || 'Upload failed');
}
}
@@ -514,20 +531,12 @@ export class EditorStateService extends AsyncHandler {
this._active_tool.set(tool);
}
- public setMode(mode: EditorMode) {
- this._mode.set(mode);
- }
-
public setActiveLayer(id: string) {
this._active_layer_id.set(id);
const size = PLACE_SIZES[id];
if (size) this._place_size.set(size);
}
- public setPlaceSize(width: number, height: number) {
- this._place_size.set({ width, height });
- }
-
public select(id: string | null) {
this._selected_id.set(id);
}
@@ -630,7 +639,7 @@ export class EditorStateService extends AsyncHandler {
const name = type.charAt(0).toUpperCase() + type.slice(1);
return {
label: `${name} ${count + 1}`,
- svg_id: `${type}-${String(count + 1).padStart(3, '0')}`,
+ svg_id: sequentialId(`${type}-`, count + 1),
};
}
@@ -739,19 +748,14 @@ export class EditorStateService extends AsyncHandler {
public async updateObject(id: string, updates: Partial) {
this.patchLocal(id, updates);
this.markDirty();
- await this._store
- .updateObject(this._floorplan_id, id, updates)
- .catch(() => undefined);
+ await this._persist([id]);
}
/** Persists whatever geometry the object currently holds after a drag */
public async commitGeometry(id: string) {
- const object = this._objects().find((o) => o.id === id);
- if (!object) return;
+ if (!this._objects().some((o) => o.id === id)) return;
this.markDirty();
- await this._store
- .updateObject(this._floorplan_id, id, { geometry: object.geometry })
- .catch(() => undefined);
+ await this._persist([id]);
}
public async deleteObject(id: string) {
@@ -851,7 +855,7 @@ export class EditorStateService extends AsyncHandler {
this.patchLocal(existing.id, values);
this._selected_id.set(existing.id);
this.markDirty();
- await this._store.updateObject(this._floorplan_id, existing.id, values);
+ await this._persist([existing.id]);
return { ...existing, ...values } as MapObject;
}
@@ -943,7 +947,7 @@ export class EditorStateService extends AsyncHandler {
await this.createObject({
object_type: 'amenity',
label: asset?.label ?? 'Amenity',
- svg_id: `amenity-${String(count + 1).padStart(3, '0')}`,
+ svg_id: sequentialId('amenity-', count + 1),
geometry: { type: 'circle', x, y, r: 12 },
layer: PLACE_LAYERS.amenity,
fill_color: '#dc2626',
@@ -961,7 +965,7 @@ export class EditorStateService extends AsyncHandler {
await this.createObject({
object_type: 'decorative',
label: asset.label,
- svg_id: `furniture-${String(count + 1).padStart(3, '0')}`,
+ svg_id: sequentialId('furniture-', count + 1),
geometry: {
type: 'rect',
x: x - asset.w / 2,
@@ -999,7 +1003,7 @@ export class EditorStateService extends AsyncHandler {
desks.push({
object_type: 'desk',
label: `Desk ${index}`,
- svg_id: `desk-${String(index).padStart(3, '0')}`,
+ svg_id: sequentialId('desk-', index),
geometry: {
type: 'rect',
x: start_x + col * (layout.deskW + layout.gap),
@@ -1064,9 +1068,7 @@ export class EditorStateService extends AsyncHandler {
}
public readonly bookable = computed(() =>
- this._objects().filter(
- (o) => o.object_type === 'room' || o.object_type === 'desk',
- ),
+ this._objects().filter(isBookable),
);
// ── Multi-select ────────────────────────────────────────────────────────
@@ -1279,35 +1281,11 @@ export class EditorStateService extends AsyncHandler {
await this._store.saveCanvasState(this._floorplan_id, state);
// Edits made while writing stay pending for the next save
if (this._change_count === change) this._dirty.set(false);
- this._last_saved.set(new Date());
this._error.set('');
} catch (error) {
- this._error.set(this._message(error, 'Failed to save'));
+ this._error.set(errorMessage(error) || 'Failed to save');
} finally {
this._saving.set(false);
}
}
-
- public async exportCsv() {
- const csv = await this._store.exportObjectsCsv(this._floorplan_id);
- const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
- const link = document.createElement('a');
- link.href = url;
- link.download = `objects-${this._floorplan_id}.csv`;
- link.click();
- URL.revokeObjectURL(url);
- }
-
- public async importCsv(file: File) {
- await this._store.importObjectsCsv(
- this._floorplan_id,
- await file.text(),
- );
- this._objects.set(await this._store.listObjects(this._floorplan_id));
- this.markDirty();
- }
-
- private _message(error: unknown, fallback: string) {
- return error instanceof Error ? error.message : fallback;
- }
}
diff --git a/apps/map-studio/src/app/editor/editor.component.ts b/apps/map-studio/src/app/editor/editor.component.ts
index f637dc34ec0..15949fc9c13 100644
--- a/apps/map-studio/src/app/editor/editor.component.ts
+++ b/apps/map-studio/src/app/editor/editor.component.ts
@@ -10,12 +10,14 @@ import {
viewChild,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
+import { errorMessage } from '@placeos/common';
import { IconComponent } from '@placeos/components';
import type { DetectedRoom } from '../data/floorplan-ai.service';
import { FloorplanAiService } from '../data/floorplan-ai.service';
import { MapObject } from '../data/types';
import { AssetPickerComponent } from './asset-picker.component';
+import { isBookable } from './availability';
import { AvailabilityPanelComponent } from './availability-panel.component';
import { CanvasComponent } from './canvas.component';
import {
@@ -950,8 +952,7 @@ export class EditorComponent {
.objects()
.filter(
(object) =>
- (object.object_type === 'room' ||
- object.object_type === 'desk') &&
+ isBookable(object) &&
object.label?.toLowerCase().includes(query),
)
.slice(0, 8);
@@ -1222,7 +1223,7 @@ export class EditorComponent {
);
} catch (error) {
this._toasts.show(
- this._errorMessage(error, 'Outline detection failed'),
+ errorMessage(error) || 'Outline detection failed',
'error',
);
} finally {
@@ -1255,7 +1256,7 @@ export class EditorComponent {
);
} catch (error) {
this._toasts.show(
- this._errorMessage(error, 'Room detection failed'),
+ errorMessage(error) || 'Room detection failed',
'error',
);
} finally {
@@ -1302,10 +1303,6 @@ export class EditorComponent {
);
}
- private _errorMessage(error: unknown, fallback: string) {
- return error instanceof Error ? error.message : fallback;
- }
-
/** The multi-selection when it holds the selected object, else the selected object */
private _selectionIds(): string[] {
const multi = this.state.multi_select();
diff --git a/apps/map-studio/src/app/editor/geometry.ts b/apps/map-studio/src/app/editor/geometry.ts
index ab78a678126..898d90140f7 100644
--- a/apps/map-studio/src/app/editor/geometry.ts
+++ b/apps/map-studio/src/app/editor/geometry.ts
@@ -1,3 +1,4 @@
+import { ObjectGeometry } from '../data/types';
import { Handle } from './constants';
export interface Point {
@@ -194,6 +195,29 @@ export function boundsOf(points: Point[]) {
};
}
+/**
+ * Bounding box of an object's geometry, in geometry field order. A circle
+ * with no radius gets the 12 unit radius the canvas draws it with.
+ */
+export function objectBounds(geometry: ObjectGeometry) {
+ if (geometry.points?.length) return boundsOf(geometry.points);
+ if (geometry.type === 'circle') {
+ const r = geometry.r ?? 12;
+ return {
+ x: (geometry.x ?? 0) - r,
+ y: (geometry.y ?? 0) - r,
+ width: r * 2,
+ height: r * 2,
+ };
+ }
+ return {
+ x: geometry.x ?? 0,
+ y: geometry.y ?? 0,
+ width: geometry.width ?? 0,
+ height: geometry.height ?? 0,
+ };
+}
+
/**
* Aligns a dragged rectangle to the edges and centres of its neighbours.
* Returns the snapped origin plus the guides that should be drawn.
diff --git a/apps/map-studio/src/app/editor/minimap.component.ts b/apps/map-studio/src/app/editor/minimap.component.ts
index abe20f4a9cc..6c9073d1db4 100644
--- a/apps/map-studio/src/app/editor/minimap.component.ts
+++ b/apps/map-studio/src/app/editor/minimap.component.ts
@@ -13,20 +13,10 @@ import {
import { IconComponent } from '@placeos/components';
import { MapObject } from '../data/types';
+import { TYPE_ACCENT_COLORS } from './constants';
import { EditorStateService } from './editor-state.service';
import { importedObject } from './source-svg';
-const TYPE_COLORS: Record = {
- room: '#3b82f6',
- desk: '#22c55e',
- zone: '#a855f7',
- area: '#f59e0b',
- amenity: '#06b6d4',
- decorative: '#6b7280',
- parking: '#8b5cf6',
- locker: '#ec4899',
-};
-
/** Length of the minimap's longer side, so tall plans do not cover the canvas */
const MINIMAP_SIZE = 180;
@@ -243,7 +233,7 @@ export class MinimapComponent {
});
public readonly colorFor = (object: MapObject) =>
- TYPE_COLORS[object.object_type] ?? '#94a3b8';
+ TYPE_ACCENT_COLORS[object.object_type] ?? '#94a3b8';
public readonly pointsOf = (object: MapObject) =>
(object.geometry.points ?? []).map((p) => `${p.x},${p.y}`).join(' ');
diff --git a/apps/map-studio/src/app/editor/object-list-panel.component.ts b/apps/map-studio/src/app/editor/object-list-panel.component.ts
index 0a156f94cfa..65482cb6508 100644
--- a/apps/map-studio/src/app/editor/object-list-panel.component.ts
+++ b/apps/map-studio/src/app/editor/object-list-panel.component.ts
@@ -2,19 +2,9 @@ import { Component, computed, inject, output, signal } from '@angular/core';
import { IconComponent } from '@placeos/components';
import { MapObject } from '../data/types';
+import { TYPE_ACCENT_COLORS } from './constants';
import { EditorStateService } from './editor-state.service';
-const TYPE_COLORS: Record = {
- room: '#3b82f6',
- desk: '#22c55e',
- zone: '#a855f7',
- area: '#f59e0b',
- amenity: '#06b6d4',
- decorative: '#6b7280',
- parking: '#8b5cf6',
- locker: '#ec4899',
-};
-
const TYPE_ORDER = [
'room',
'desk',
@@ -158,7 +148,7 @@ export class ObjectListPanelComponent {
public readonly asValue = (event: Event) =>
(event.target as HTMLInputElement).value;
- public readonly colorFor = (type: string) => TYPE_COLORS[type] ?? '#6b7280';
+ public readonly colorFor = (type: string) => TYPE_ACCENT_COLORS[type] ?? '#6b7280';
public readonly plural = (type: string) =>
type === 'amenity' ? 'amenities' : `${type}s`;
diff --git a/apps/map-studio/src/app/editor/publish-panel.component.ts b/apps/map-studio/src/app/editor/publish-panel.component.ts
index b0e4650d6a1..9fc3efbebbc 100644
--- a/apps/map-studio/src/app/editor/publish-panel.component.ts
+++ b/apps/map-studio/src/app/editor/publish-panel.component.ts
@@ -5,6 +5,7 @@ import {
signal,
WritableSignal,
} from '@angular/core';
+import { errorMessage } from '@placeos/common';
import { IconComponent } from '@placeos/components';
import {
@@ -15,7 +16,7 @@ import {
import { StoreService } from '../data/store.service';
import { Floorplan, MapObject } from '../data/types';
import { EditorStateService } from './editor-state.service';
-import { bookableObjects, buildSvg, preflightChecks } from './svg-export';
+import { buildSvg, preflightChecks } from './svg-export';
import { ToastService } from './toast.service';
/** A PlaceOS list request and its result */
@@ -480,9 +481,7 @@ export class PublishPanelComponent {
this.checks().every((check) => check.pass),
);
- public readonly bookable = computed(() =>
- bookableObjects(this.state.objects()),
- );
+ public readonly bookable = this.state.bookable;
public readonly bookableSystems = computed(() =>
this.systems().items.filter((system) => system.bookable),
@@ -595,7 +594,7 @@ export class PublishPanelComponent {
if (isCurrent()) target.set({ state: 'ready', items, error: '' });
} catch (error) {
if (!isCurrent()) return;
- const message = this._message(error, 'Request failed');
+ const message = errorMessage(error) || 'Request failed';
target.set({ state: 'error', items: [], error: message });
}
}
@@ -678,7 +677,7 @@ export class PublishPanelComponent {
failed_links ? 'error' : 'success',
);
} catch (error) {
- const message = this._message(error, 'Publish failed');
+ const message = errorMessage(error) || 'Publish failed';
this.status.set(message);
this._toasts.show(message, 'error');
} finally {
@@ -705,8 +704,4 @@ export class PublishPanelComponent {
}
return failed;
}
-
- private _message(error: unknown, fallback: string) {
- return error instanceof Error ? error.message : fallback;
- }
}
diff --git a/apps/map-studio/src/app/editor/source-svg.ts b/apps/map-studio/src/app/editor/source-svg.ts
index 2a3bf781673..f810cbe7be7 100644
--- a/apps/map-studio/src/app/editor/source-svg.ts
+++ b/apps/map-studio/src/app/editor/source-svg.ts
@@ -7,6 +7,7 @@ import {
sanitiseTree,
} from '@placeos/map-editor';
import { MapObject, ObjectGeometry } from '../data/types';
+import { objectBounds } from './geometry';
export function importedObject(object: MapObject): boolean {
return object.metadata?.['source'] === 'svg-import';
@@ -39,34 +40,6 @@ function selectorPattern(id: string, selector_only = false) {
);
}
-function bounds(geometry: ObjectGeometry) {
- if (geometry.points?.length) {
- const xs = geometry.points.map((p) => p.x);
- const ys = geometry.points.map((p) => p.y);
- return {
- x: Math.min(...xs),
- y: Math.min(...ys),
- width: Math.max(...xs) - Math.min(...xs),
- height: Math.max(...ys) - Math.min(...ys),
- };
- }
- if (geometry.type === 'circle') {
- const r = geometry.r || 0;
- return {
- x: (geometry.x || 0) - r,
- y: (geometry.y || 0) - r,
- width: r * 2,
- height: r * 2,
- };
- }
- return {
- x: geometry.x || 0,
- y: geometry.y || 0,
- width: geometry.width || 0,
- height: geometry.height || 0,
- };
-}
-
function patchObject(element: Element, object: MapObject) {
const metadata = object.metadata || {};
const original = metadata['originalGeometry'] as ObjectGeometry | undefined;
@@ -74,8 +47,8 @@ function patchObject(element: Element, object: MapObject) {
original &&
JSON.stringify(original) !== JSON.stringify(object.geometry)
) {
- const before = bounds(original);
- const after = bounds(object.geometry);
+ const before = objectBounds(original);
+ const after = objectBounds(object.geometry);
const sx = before.width ? after.width / before.width : 1;
const sy = before.height ? after.height / before.height : 1;
let delta: Matrix = {
diff --git a/apps/map-studio/src/app/editor/svg-export.ts b/apps/map-studio/src/app/editor/svg-export.ts
index 1fbca7e1e86..bcc9ab72b62 100644
--- a/apps/map-studio/src/app/editor/svg-export.ts
+++ b/apps/map-studio/src/app/editor/svg-export.ts
@@ -1,4 +1,5 @@
-import { MapObject, ObjectGeometry } from '../data/types';
+import { MapObject, ObjectGeometry, ValidationIssue } from '../data/types';
+import { isBookable, toFillOpacity } from './availability';
import { TYPE_COLORS } from './constants';
import { composeSourceSvg, importedObject } from './source-svg';
@@ -42,22 +43,60 @@ export function sequentialIdRange(
: `${first} … ${sequentialId(prefix, start + count - 1)}`;
}
-/** Rooms and desks — the objects PlaceOS can bind a booking to */
-export const bookableObjects = (objects: MapObject[]) =>
- objects.filter((o) => o.object_type === 'room' || o.object_type === 'desk');
+/** How many times each id occurs */
+function idCounts(ids: string[]): Map {
+ const counts = new Map();
+ for (const id of ids) counts.set(id, (counts.get(id) ?? 0) + 1);
+ return counts;
+}
+
+/**
+ * Problems in the editor's objects: a missing label (warning), empty
+ * geometry, or an svg id that more than one object uses (errors).
+ */
+export function validateObjects(
+ objects: readonly MapObject[],
+): ValidationIssue[] {
+ const issues: ValidationIssue[] = [];
+ for (const object of objects) {
+ if (!object.label) {
+ issues.push({
+ type: 'warning',
+ objectId: object.id,
+ message: `Object ${object.id} (${object.object_type}) has no label`,
+ });
+ }
+ if (!object.geometry || Object.keys(object.geometry).length === 0) {
+ issues.push({
+ type: 'error',
+ objectId: object.id,
+ message: `Object ${object.id} (${object.object_type}) has empty geometry`,
+ });
+ }
+ }
+ const svg_ids = idCounts(
+ objects.filter((o) => o.svg_id).map((o) => o.svg_id),
+ );
+ for (const [svg_id, count] of svg_ids) {
+ if (count < 2) continue;
+ for (const object of objects.filter((o) => o.svg_id === svg_id)) {
+ issues.push({
+ type: 'error',
+ objectId: object.id,
+ message: `Duplicate svg_id "${svg_id}" on object ${object.id}`,
+ });
+ }
+ }
+ return issues;
+}
/**
* Checks that must pass before a map can be published to PlaceOS.
* Mirrors the reference application's pre-publish checklist.
*/
export function preflightChecks(objects: MapObject[]): PreflightCheck[] {
- const bookable = bookableObjects(objects);
-
- const duplicates = new Map();
- for (const object of bookable) {
- const id = idOf(object);
- duplicates.set(id, (duplicates.get(id) ?? 0) + 1);
- }
+ const bookable = objects.filter(isBookable);
+ const duplicates = idCounts(bookable.map(idOf));
return [
{
@@ -100,15 +139,6 @@ export function preflightChecks(objects: MapObject[]): PreflightCheck[] {
];
}
-/** Convert a hex colour into rgba() at the given opacity */
-function toFillOpacity(hex: string, opacity: number): string {
- const clean = hex.replace('#', '').slice(0, 6);
- const r = parseInt(clean.slice(0, 2), 16) || 0;
- const g = parseInt(clean.slice(2, 4), 16) || 0;
- const b = parseInt(clean.slice(4, 6), 16) || 0;
- return `rgba(${r},${g},${b},${opacity})`;
-}
-
function exportFill(object: MapObject): string {
const base = TYPE_COLORS[object.object_type] ?? '#4b5563';
const color = object.fill_color || base;
diff --git a/apps/map-studio/src/app/editor/validation-panel.component.ts b/apps/map-studio/src/app/editor/validation-panel.component.ts
index 1df299af5a2..d90c35bbe1d 100644
--- a/apps/map-studio/src/app/editor/validation-panel.component.ts
+++ b/apps/map-studio/src/app/editor/validation-panel.component.ts
@@ -1,10 +1,13 @@
import { Component, computed, inject, output, signal } from '@angular/core';
import { IconComponent } from '@placeos/components';
-import { StoreService } from '../data/store.service';
import { MapObject, ValidationIssue } from '../data/types';
import { EditorStateService } from './editor-state.service';
-import { PreflightCheck, preflightChecks } from './svg-export';
+import {
+ PreflightCheck,
+ preflightChecks,
+ validateObjects,
+} from './svg-export';
@Component({
selector: 'map-studio-validation-panel',
@@ -17,20 +20,13 @@ import { PreflightCheck, preflightChecks } from './svg-export';
- @if (error(); as message) {
-
- {{ message }}
-
- }
-
- @if (has_run() && !running()) {
+ @if (has_run()) {
@@ -58,11 +54,11 @@ import { PreflightCheck, preflightChecks } from './svg-export';
}
- @if (!has_run() && !running()) {
+ @if (!has_run()) {
Run the checks to find problems before you publish.
- } @else if (stale() && !running()) {
+ } @else if (stale()) {
@@ -73,8 +69,7 @@ import { PreflightCheck, preflightChecks } from './svg-export';
!stale() &&
has_run() &&
!issues().length &&
- !failed_checks().length &&
- !running()
+ !failed_checks().length
) {
@@ -130,7 +125,6 @@ import { PreflightCheck, preflightChecks } from './svg-export';
imports: [IconComponent],
})
export class ValidationPanelComponent {
- private readonly _store = inject(StoreService);
public readonly state = inject(EditorStateService);
/** Emitted when an issue is picked, so the editor can show the object */
@@ -138,9 +132,7 @@ export class ValidationPanelComponent {
public readonly issues = signal([]);
public readonly checks = signal([]);
- public readonly running = signal(false);
public readonly has_run = signal(false);
- public readonly error = signal('');
/** Objects the last run checked, to tell when the results are out of date */
private readonly _checked = signal(null);
@@ -161,24 +153,14 @@ export class ValidationPanelComponent {
public readonly warningCount = () =>
this.issues().filter((i) => i.type === 'warning').length;
- public async run() {
- const floorplan = this.state.floorplan();
- if (!floorplan) return;
- this.running.set(true);
- this.error.set('');
- try {
- const objects = this.state.objects();
- this.issues.set(await this._store.validateFloorplan(floorplan.id));
- this.checks.set(preflightChecks(objects));
- this._checked.set(objects);
- this.has_run.set(true);
- } catch (error) {
- this.error.set(
- error instanceof Error ? error.message : 'Validation failed',
- );
- } finally {
- this.running.set(false);
- }
+ /** Checks the objects in the editor, so the results match what is shown */
+ public run() {
+ if (!this.state.floorplan()) return;
+ const objects = this.state.objects();
+ this.issues.set(validateObjects(objects));
+ this.checks.set(preflightChecks(objects));
+ this._checked.set(objects);
+ this.has_run.set(true);
}
/** Objects by id, so each issue finds its object without a scan */
diff --git a/apps/map-studio/src/app/kiosk/availability-polling.service.ts b/apps/map-studio/src/app/kiosk/availability-polling.service.ts
index 87b3a271f68..0dbeeca172e 100644
--- a/apps/map-studio/src/app/kiosk/availability-polling.service.ts
+++ b/apps/map-studio/src/app/kiosk/availability-polling.service.ts
@@ -4,6 +4,7 @@ import { AsyncHandler } from '@placeos/common';
import { PlaceOSService, PlaceOSSystem } from '../data/placeos.service';
import { AvailabilityState, MapObject } from '../data/types';
+import { isBookable } from '../editor/availability';
const ROOM_STATES: AvailabilityState[] = [
'free',
@@ -113,11 +114,7 @@ export class AvailabilityPollingService extends AsyncHandler {
}
const states: Record = {};
for (const object of this._objects) {
- if (
- object.object_type !== 'room' &&
- object.object_type !== 'desk'
- )
- continue;
+ if (!isBookable(object)) continue;
const system = by_map_id.get(object.svg_id || object.id);
const room = object.object_type === 'room';
states[object.id] =
diff --git a/apps/map-studio/src/app/kiosk/floor-plan-2d.component.ts b/apps/map-studio/src/app/kiosk/floor-plan-2d.component.ts
index 57da9446609..d0e96d218dc 100644
--- a/apps/map-studio/src/app/kiosk/floor-plan-2d.component.ts
+++ b/apps/map-studio/src/app/kiosk/floor-plan-2d.component.ts
@@ -16,7 +16,11 @@ import {
import { IconComponent } from '@placeos/components';
import { AvailabilityState, Floorplan, MapObject } from '../data/types';
-import { STATE_COLORS } from '../editor/availability';
+import {
+ isBookable,
+ STATE_COLORS,
+ toFillOpacity,
+} from '../editor/availability';
import { composeSourceSvg, importedObject } from '../editor/source-svg';
import { computeHeatmap } from './heatmap';
@@ -37,12 +41,6 @@ export const STATE_SYMBOLS: Record = {
export const stateLabel = (state: AvailabilityState) =>
state.replace(/-/g, ' ');
-/** `#rrggbb` to `rgba()` so fills share the legend palette */
-function withAlpha(hex: string, alpha: number) {
- const value = parseInt(hex.slice(1), 16);
- return `rgba(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}, ${alpha})`;
-}
-
const DEFAULT_FILL = 'rgba(100, 116, 139, 0.2)';
/** Desks with no state yet stay neutral rather than looking free */
const DEFAULT_DESK_FILL = 'rgba(100, 116, 139, 0.45)';
@@ -910,19 +908,16 @@ export class FloorPlan2dComponent {
public fillOf(object: MapObject): string {
const desk = object.object_type === 'desk';
// Under the heatmap, rooms and desks fade so the colours read through
- if (this.heatmap_enabled() && (desk || object.object_type === 'room'))
+ if (this.heatmap_enabled() && isBookable(object))
return 'rgba(255, 255, 255, 0.08)';
const state = this.availability()[object.id];
// Desks read better on a dark plan when their fill is more solid
- if (state) return withAlpha(STATE_COLORS[state], desk ? 0.7 : 0.55);
+ if (state) return toFillOpacity(STATE_COLORS[state], desk ? 0.7 : 0.55);
return desk ? DEFAULT_DESK_FILL : DEFAULT_FILL;
}
public strokeOf(object: MapObject): string {
- if (
- this.heatmap_enabled() &&
- (object.object_type === 'room' || object.object_type === 'desk')
- )
+ if (this.heatmap_enabled() && isBookable(object))
return 'rgba(255, 255, 255, 0.2)';
const state = this.availability()[object.id];
return state ? STATE_COLORS[state] : DEFAULT_STROKE;
diff --git a/apps/map-studio/src/app/kiosk/heatmap.ts b/apps/map-studio/src/app/kiosk/heatmap.ts
index 7e088dc06b3..9cc58971086 100644
--- a/apps/map-studio/src/app/kiosk/heatmap.ts
+++ b/apps/map-studio/src/app/kiosk/heatmap.ts
@@ -1,4 +1,5 @@
import { AvailabilityState, MapObject } from '../data/types';
+import { isBookable } from '../editor/availability';
export interface HeatmapCell {
x: number;
@@ -61,10 +62,7 @@ export function computeHeatmap(
Array.from({ length: cols }, () => ({ score: 0, count: 0 })),
);
- const bookable = objects.filter(
- (o) =>
- o.visible && (o.object_type === 'room' || o.object_type === 'desk'),
- );
+ const bookable = objects.filter((o) => o.visible && isBookable(o));
const radius = 2;
for (const object of bookable) {
diff --git a/apps/map-studio/src/app/kiosk/kiosk.component.ts b/apps/map-studio/src/app/kiosk/kiosk.component.ts
index 84ea73bc120..14183bcbfb7 100644
--- a/apps/map-studio/src/app/kiosk/kiosk.component.ts
+++ b/apps/map-studio/src/app/kiosk/kiosk.component.ts
@@ -14,7 +14,7 @@ import {
MapObject,
Project,
} from '../data/types';
-import { STATE_COLORS } from '../editor/availability';
+import { isBookable, STATE_COLORS } from '../editor/availability';
import { AvailabilityPollingService } from './availability-polling.service';
import {
FloorPlan2dComponent,
@@ -483,9 +483,7 @@ export class KioskComponent extends AsyncHandler {
: this.objects();
return pool
.filter(
- (o) =>
- (o.object_type === 'room' || o.object_type === 'desk') &&
- o.label?.toLowerCase().includes(query),
+ (o) => isBookable(o) && o.label?.toLowerCase().includes(query),
)
.sort(
(a, b) =>
diff --git a/apps/map-studio/src/app/pages/import.component.ts b/apps/map-studio/src/app/pages/import.component.ts
index 171153c2747..e770b84c90b 100644
--- a/apps/map-studio/src/app/pages/import.component.ts
+++ b/apps/map-studio/src/app/pages/import.component.ts
@@ -3,11 +3,13 @@ import { FormsModule } from '@angular/forms';
import { MatFormField } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
+import { errorMessage } from '@placeos/common';
import { IconComponent } from '@placeos/components';
import { StoreService } from '../data/store.service';
import { analyzeSvg, SvgAnalysis } from '../data/svg-import';
-import { MapObjectType, Project } from '../data/types';
+import { Project } from '../data/types';
+import { OBJECT_TYPES } from '../editor/constants';
type ImportStep = 'upload' | 'review' | 'map' | 'confirm';
@@ -18,17 +20,6 @@ const STEPS: { key: ImportStep; label: string }[] = [
{ key: 'confirm', label: 'Confirm' },
];
-const OBJECT_TYPES: MapObjectType[] = [
- 'room',
- 'desk',
- 'zone',
- 'area',
- 'amenity',
- 'decorative',
- 'parking',
- 'locker',
-];
-
const SPACE_LABELS: Record = {
room: 'Bookable rooms',
desk: 'Bookable desks',
@@ -737,7 +728,7 @@ export class ImportComponent {
} catch (error) {
this.analysis.set(null);
this.mappings.set([]);
- this.error.set(this._message(error, 'Failed to analyse SVG'));
+ this.error.set(errorMessage(error) || 'Failed to analyse SVG');
} finally {
this.analyzing.set(false);
}
@@ -820,7 +811,7 @@ export class ImportComponent {
});
this._router.navigate(['/editor', result.floorplan_id]);
} catch (error) {
- this.error.set(this._message(error, 'Import failed'));
+ this.error.set(errorMessage(error) || 'Import failed');
} finally {
this.importing.set(false);
}
@@ -831,11 +822,7 @@ export class ImportComponent {
this.target_project.set(await this._store.getProject(id));
} catch (error) {
this.project_missing.set(true);
- this.error.set(this._message(error, 'Project not found'));
+ this.error.set(errorMessage(error) || 'Project not found');
}
}
-
- private _message(error: unknown, fallback: string) {
- return error instanceof Error ? error.message : fallback;
- }
}
diff --git a/apps/map-studio/src/app/pages/project-detail.component.ts b/apps/map-studio/src/app/pages/project-detail.component.ts
index c2775a76952..c1b74a58ed2 100644
--- a/apps/map-studio/src/app/pages/project-detail.component.ts
+++ b/apps/map-studio/src/app/pages/project-detail.component.ts
@@ -13,6 +13,7 @@ import { FormsModule } from '@angular/forms';
import { MatFormField, MatLabel } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { ActivatedRoute, RouterLink } from '@angular/router';
+import { errorMessage } from '@placeos/common';
import { IconComponent } from '@placeos/components';
import { StoreService } from '../data/store.service';
@@ -495,7 +496,7 @@ export class ProjectDetailComponent {
this.project.set(project);
await this._loadPreviews(project.floorplans ?? []);
} catch (error) {
- this.error.set(this._message(error, 'Failed to load project'));
+ this.error.set(errorMessage(error) || 'Failed to load project');
} finally {
this.loading.set(false);
}
@@ -537,7 +538,7 @@ export class ProjectDetailComponent {
if (save_error) this.error.set(save_error);
}
} catch (error) {
- this.error.set(this._message(error, 'Failed to rename'));
+ this.error.set(errorMessage(error) || 'Failed to rename');
}
}
@@ -566,7 +567,7 @@ export class ProjectDetailComponent {
}
if (save_error) this.error.set(save_error);
} catch (error) {
- this.error.set(this._message(error, 'Failed to reorder floors'));
+ this.error.set(errorMessage(error) || 'Failed to reorder floors');
} finally {
this.reordering.set(false);
}
@@ -586,7 +587,7 @@ export class ProjectDetailComponent {
this.floor_name.set('');
this.show_form.set(false);
} catch (error) {
- this.error.set(this._message(error, 'Failed to add floor'));
+ this.error.set(errorMessage(error) || 'Failed to add floor');
} finally {
this.creating.set(false);
}
@@ -607,7 +608,7 @@ export class ProjectDetailComponent {
floorplans: this.floorplans().filter((f) => f.id !== floor.id),
});
} catch (error) {
- this.error.set(this._message(error, 'Failed to delete floor'));
+ this.error.set(errorMessage(error) || 'Failed to delete floor');
}
}
@@ -654,8 +655,4 @@ export class ProjectDetailComponent {
private _revokePreviews(previews: Record) {
for (const url of Object.values(previews)) URL.revokeObjectURL(url);
}
-
- private _message(error: unknown, fallback: string) {
- return error instanceof Error ? error.message : fallback;
- }
}
diff --git a/apps/map-studio/src/app/pages/projects.component.ts b/apps/map-studio/src/app/pages/projects.component.ts
index a3e5cb976d5..fafc53f7c94 100644
--- a/apps/map-studio/src/app/pages/projects.component.ts
+++ b/apps/map-studio/src/app/pages/projects.component.ts
@@ -11,6 +11,7 @@ import { FormsModule } from '@angular/forms';
import { MatFormField, MatLabel } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { Router, RouterLink } from '@angular/router';
+import { errorMessage } from '@placeos/common';
import { IconComponent } from '@placeos/components';
import { lastUpdated, StoreService } from '../data/store.service';
@@ -282,7 +283,7 @@ export class ProjectsComponent {
try {
this.projects.set(await this._store.listProjects());
} catch (error) {
- this.error.set(this._message(error, 'Failed to load projects'));
+ this.error.set(errorMessage(error) || 'Failed to load projects');
} finally {
this.loading.set(false);
}
@@ -305,7 +306,7 @@ export class ProjectsComponent {
);
await this._router.navigate(['/project', project.id]);
} catch (error) {
- this.error.set(this._message(error, 'Failed to create project'));
+ this.error.set(errorMessage(error) || 'Failed to create project');
} finally {
this.creating.set(false);
}
@@ -326,11 +327,7 @@ export class ProjectsComponent {
list.filter((p) => p.id !== project.id),
);
} catch (error) {
- this.error.set(this._message(error, 'Failed to delete project'));
+ this.error.set(errorMessage(error) || 'Failed to delete project');
}
}
-
- private _message(error: unknown, fallback: string) {
- return error instanceof Error ? error.message : fallback;
- }
}
diff --git a/apps/map-studio/src/tests/editor-state.service.spec.ts b/apps/map-studio/src/tests/editor-state.service.spec.ts
index fa37c5a2e04..8d6c55133f5 100644
--- a/apps/map-studio/src/tests/editor-state.service.spec.ts
+++ b/apps/map-studio/src/tests/editor-state.service.spec.ts
@@ -80,6 +80,29 @@ describe('EditorStateService', () => {
expect(editor.error()).toBe('');
});
+ it('stores an uploaded image size so the kiosk uses the same canvas', async () => {
+ // jsdom decodes no images; report a fixed natural size instead
+ vi.stubGlobal(
+ 'Image',
+ class {
+ public naturalWidth = 1600;
+ public naturalHeight = 900;
+ public onload: (() => void) | null = null;
+ public set src(_: string) {
+ queueMicrotask(() => this.onload?.());
+ }
+ },
+ );
+ vi.spyOn(store, 'getSourceImageUrl').mockResolvedValue('blob:plan');
+
+ await editor.uploadSourceImage(new File(['png'], 'plan.png'));
+
+ const saved = await store.getFloorplan(floorplan_id);
+ expect([saved.canvas_width, saved.canvas_height]).toEqual([1600, 900]);
+ expect(editor.canvas_width()).toBe(1600);
+ vi.unstubAllGlobals();
+ });
+
it('requires confirmation before replacing a legacy SVG draft', async () => {
await store.updateFloorplan(floorplan_id, {
source_type: 'svg-import',
@@ -626,3 +649,50 @@ describe('CanvasComponent furniture art', () => {
expect(markup).toContain('#333');
});
});
+
+describe('CanvasComponent rotate handle', () => {
+ it('turns a rotated shape by the angle the pointer moves', async () => {
+ TestBed.configureTestingModule({
+ providers: [
+ provideZonelessChangeDetection(),
+ { provide: DbService, useClass: MapDbService },
+ EditorStateService,
+ ],
+ });
+ const store = TestBed.inject(StoreService);
+ const editor = TestBed.inject(EditorStateService);
+ const floorplan = await store.addFloorplan('p1', 'Level 1');
+ await store.createObject(floorplan.id, {
+ label: 'Room 1',
+ object_type: 'room',
+ layer: 'rooms',
+ geometry: {
+ type: 'rect',
+ x: 0,
+ y: 0,
+ width: 100,
+ height: 100,
+ rotation: 90,
+ },
+ });
+ await editor.load(floorplan.id);
+ const fixture = TestBed.createComponent(CanvasComponent);
+ await fixture.whenStable();
+ // jsdom lays nothing out; map client pixels 1:1 onto canvas units
+ const svg: SVGSVGElement = fixture.nativeElement.querySelector('svg');
+ svg.getBoundingClientRect = () =>
+ ({ left: 0, top: 0, width: 1000, height: 1000 }) as DOMRect;
+ Object.defineProperty(svg, 'viewBox', {
+ value: { baseVal: { width: 1000 } },
+ });
+ const canvas = fixture.componentInstance;
+ const at = (type: string, clientX: number, clientY: number) =>
+ new MouseEvent(type, { button: 0, clientX, clientY });
+
+ // At 90° the handle sits right of the center (50, 50), not above it
+ canvas.startRotate(at('mousedown', 150, 50), editor.objects()[0]);
+ canvas.onMouseMove(at('mousemove', 50, 150));
+
+ expect(editor.objects()[0].geometry.rotation).toBe(180);
+ });
+});
diff --git a/apps/map-studio/src/tests/placeos.service.spec.ts b/apps/map-studio/src/tests/placeos.service.spec.ts
index 97243d46111..38960ccca03 100644
--- a/apps/map-studio/src/tests/placeos.service.spec.ts
+++ b/apps/map-studio/src/tests/placeos.service.spec.ts
@@ -94,6 +94,20 @@ function stubFetch(authority_ok: boolean) {
};
}
if (url.includes('/zones?')) return { ok: true, json: async () => [] };
+ if (url.includes('/systems?')) {
+ // One full page, then a short one
+ const offset = Number(
+ new URL(url, 'http://x').searchParams.get('offset'),
+ );
+ const size = offset < 100 ? 100 : 20;
+ return {
+ ok: true,
+ json: async () =>
+ Array.from({ length: size }, (_, i) => ({
+ id: `sys-${offset + i}`,
+ })),
+ };
+ }
if (url.includes('/LLM_1/chat'))
return {
ok: true,
@@ -272,7 +286,9 @@ describe('PlaceOSService', () => {
await expect(service.getActiveOrganisation()).resolves.toEqual({
id: 'org-2',
});
- await expect(service.getLevelSvg('level-1')).resolves.toContain('
+ @if (!configured()) {
+
+
+ Public events are not configured yet
+
+
+ Configure a default public events source in the
+ authority settings, or provide one with the shared
+ event link.
+
+
+ }
`,
@@ -81,4 +100,6 @@ export class PublicPageShellComponent {
public readonly description = input(
'Browse public events, view details, and register to attend.',
);
+ /** False when there is no events source to show */
+ public readonly configured = input(true);
}
diff --git a/apps/public/src/test-setup.ts b/apps/public/src/test-setup.ts
deleted file mode 100644
index cfd12bbcfff..00000000000
--- a/apps/public/src/test-setup.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { installTestStorage } from 'libs/common/src/test-storage';
-import { defineGlobalsInjections, Spectator } from '@ngneat/spectator';
-import { setNotifyOutlet } from '@placeos/common';
-import { TranslatePipe } from '@placeos/components';
-import { MockPipe } from 'ng-mocks';
-import { TextDecoder, TextEncoder } from 'util';
-
-global.TextEncoder = TextEncoder;
-global.TextDecoder = TextDecoder;
-installTestStorage();
-
-// Under the zoneless unit-test builder, `fixture.detectChanges()` only
-// refreshes views that were explicitly marked dirty. Plain host-property
-// changes (`setHostInput`), overridden getters and reassigned signals do not
-// mark the root view, so the main CD pass skips them while dev-mode
-// `checkNoChanges` still catches the new value -> NG0100. Emulate zone.js's
-// full dirty-check by marking the fixture root for check before every
-// `detectChanges()`.
-const _dom_spectator_proto: any = Object.getPrototypeOf(Spectator.prototype);
-const _original_detect_changes = _dom_spectator_proto.detectChanges;
-_dom_spectator_proto.detectChanges = function () {
- try {
- this.fixture?.componentRef?.changeDetectorRef?.markForCheck();
- } catch {
- // ignore - fixture may not expose a componentRef
- }
- return _original_detect_changes.apply(this, arguments);
-};
-
-setNotifyOutlet(null, true);
-
-defineGlobalsInjections({
- declarations: [MockPipe(TranslatePipe)],
-});
diff --git a/apps/public/src/tests/public-event.helpers.spec.ts b/apps/public/src/tests/public-event.helpers.spec.ts
index 8ff015dc701..a1af88dcae2 100644
--- a/apps/public/src/tests/public-event.helpers.spec.ts
+++ b/apps/public/src/tests/public-event.helpers.spec.ts
@@ -1,6 +1,9 @@
+import { convertToParamMap } from '@angular/router';
import {
eventDescriptionText,
isSystemEvent,
+ preservedQueryParams,
+ querySystemId,
} from '../app/public-event.helpers';
describe('public event helpers', () => {
@@ -22,6 +25,26 @@ describe('public event helpers', () => {
);
});
+ it('should read the source id from the first query param that is set', () => {
+ expect(querySystemId(convertToParamMap({ id: 'c', system: 'b' }))).toBe(
+ 'b',
+ );
+ expect(querySystemId(convertToParamMap({}))).toBe('');
+ });
+
+ it('should keep only the query params that do not pick a source or event', () => {
+ expect(
+ preservedQueryParams({
+ system_id: 'a',
+ system: 'b',
+ id: 'c',
+ event_id: 'e',
+ direct_event: 'true',
+ lang: 'en',
+ }),
+ ).toEqual({ lang: 'en' });
+ });
+
it('should read description text without creating elements in the page', () => {
const create = vi.spyOn(document, 'createElement');
expect(
diff --git a/apps/public/src/tests/public-events.component.spec.ts b/apps/public/src/tests/public-events.component.spec.ts
index e7219ea99b8..990e7ae3072 100644
--- a/apps/public/src/tests/public-events.component.spec.ts
+++ b/apps/public/src/tests/public-events.component.spec.ts
@@ -150,6 +150,16 @@ describe('PublicEventsComponent', () => {
expect(fixture.componentInstance.registered()).toBe(true);
});
+ it('stays unregistered when registration fails', async () => {
+ service.authenticated.set(true);
+ service.guest.set({ name: 'A', email: 'a@b.com' });
+ service.default_system_id.set('sys-1');
+ service.register.mockRejectedValueOnce(new Error('Full'));
+ const fixture = await init();
+ await fixture.componentInstance.register({ id: 'e1' });
+ expect(fixture.componentInstance.registered()).toBe(false);
+ });
+
it('does not load events when there is no system id', async () => {
service.authenticated.set(true);
service.guest.set({ name: 'A', email: 'a@b.com' });
diff --git a/apps/public/src/tests/public-events.service.spec.ts b/apps/public/src/tests/public-events.service.spec.ts
index f4b8edc1aab..83aaa365553 100644
--- a/apps/public/src/tests/public-events.service.spec.ts
+++ b/apps/public/src/tests/public-events.service.spec.ts
@@ -56,6 +56,19 @@ describe('PublicEventsService', () => {
expect(service.loading()).toBe('');
});
+ it('should show an authority load failure as the error', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({ ok: false } as Response),
+ );
+ const service = TestBed.inject(PublicEventsService);
+
+ await service.init();
+
+ expect(service.error()).toBe('Unable to load PlaceOS authority.');
+ vi.unstubAllGlobals();
+ });
+
it('should ignore stored values that are not guest details', () => {
localStorage.setItem(
'PLACEOS.public.guest',
diff --git a/apps/public/src/tests/public-page-shell.component.spec.ts b/apps/public/src/tests/public-page-shell.component.spec.ts
index a3d5b1f5c06..2e2ad1c4418 100644
--- a/apps/public/src/tests/public-page-shell.component.spec.ts
+++ b/apps/public/src/tests/public-page-shell.component.spec.ts
@@ -44,6 +44,19 @@ describe('PublicPageShellComponent', () => {
expect(projected?.textContent).toContain('Projected body');
});
+ it('shows the not-configured message only when not configured', () => {
+ const fixture = TestBed.createComponent(PublicPageShellComponent);
+ fixture.detectChanges();
+ expect(fixture.nativeElement.textContent).not.toContain(
+ 'not configured yet',
+ );
+ fixture.componentRef.setInput('configured', false);
+ fixture.detectChanges();
+ expect(fixture.nativeElement.textContent).toContain(
+ 'Public events are not configured yet',
+ );
+ });
+
it('falls back to the default logo and description', () => {
const fixture = TestBed.createComponent(PublicPageShellComponent);
fixture.detectChanges();
diff --git a/apps/public/tsconfig.app.json b/apps/public/tsconfig.app.json
index dc5d1bbf990..3b4bd466e47 100644
--- a/apps/public/tsconfig.app.json
+++ b/apps/public/tsconfig.app.json
@@ -9,8 +9,7 @@
"src/**/*.spec.ts",
"src/**/*.test.ts",
"jest.config.ts",
- "jest.config.cts",
- "src/test-setup.ts"
+ "jest.config.cts"
],
"angularCompilerOptions": {
"extendedDiagnostics": {
diff --git a/apps/public/tsconfig.spec.json b/apps/public/tsconfig.spec.json
index 026c8ea3ddb..acba446ea12 100644
--- a/apps/public/tsconfig.spec.json
+++ b/apps/public/tsconfig.spec.json
@@ -12,7 +12,7 @@
"noImplicitReturns": false,
"noFallthroughCasesInSwitch": false
},
- "files": ["src/test-setup.ts"],
+ "files": ["../../shared/test-setup-base.ts", "../../shared/test-setup.ts"],
"include": ["**/*.spec.ts", "**/*.d.ts"],
"angularCompilerOptions": {
"extendedDiagnostics": {
diff --git a/apps/redirect/project.json b/apps/redirect/project.json
index e9f1517dbdf..0d402419801 100644
--- a/apps/redirect/project.json
+++ b/apps/redirect/project.json
@@ -84,7 +84,7 @@
"tsConfig": "apps/redirect/tsconfig.spec.json",
"setupFiles": [
"libs/common/src/test-storage.ts",
- "apps/redirect/src/test-setup.ts"
+ "shared/test-setup-base.ts"
],
"isolate": true,
"watch": false,
diff --git a/apps/redirect/src/app/app.component.ts b/apps/redirect/src/app/app.component.ts
index 46d4f794997..234e576f60e 100644
--- a/apps/redirect/src/app/app.component.ts
+++ b/apps/redirect/src/app/app.component.ts
@@ -24,7 +24,7 @@ import {
{{ initialisation_error() }}
} @else {
-
Redirecting
+
Redirecting
}
@@ -48,7 +48,7 @@ import {
font-family: Arial, sans-serif;
}
- p:after {
+ .redirecting:after {
display: inline-block;
content: '';
vertical-align: bottom;
@@ -80,11 +80,6 @@ import {
background-color: #424242;
border-color: #888;
}
-
- input {
- border-color: #666;
- color: #fff;
- }
}
`,
],
diff --git a/apps/redirect/src/test-setup.ts b/apps/redirect/src/test-setup.ts
deleted file mode 100644
index 8d870db56db..00000000000
--- a/apps/redirect/src/test-setup.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { installTestStorage } from 'libs/common/src/test-storage';
-import { Spectator } from '@ngneat/spectator';
-import { setNotifyOutlet } from '@placeos/common';
-import { TextDecoder, TextEncoder } from 'util';
-
-global.TextEncoder = TextEncoder;
-global.TextDecoder = TextDecoder;
-installTestStorage();
-
-// Under the zoneless unit-test builder, `fixture.detectChanges()` only
-// refreshes views that were explicitly marked dirty. Plain host-property
-// changes (`setHostInput`), overridden getters and reassigned signals do not
-// mark the root view, so the main CD pass skips them while dev-mode
-// `checkNoChanges` still catches the new value -> NG0100. Emulate zone.js's
-// full dirty-check by marking the fixture root for check before every
-// `detectChanges()`.
-const _dom_spectator_proto: any = Object.getPrototypeOf(Spectator.prototype);
-const _original_detect_changes = _dom_spectator_proto.detectChanges;
-_dom_spectator_proto.detectChanges = function () {
- try {
- this.fixture?.componentRef?.changeDetectorRef?.markForCheck();
- } catch {
- // ignore - fixture may not expose a componentRef
- }
- return _original_detect_changes.apply(this, arguments);
-};
-
-setNotifyOutlet(null, true);
diff --git a/apps/redirect/tsconfig.spec.json b/apps/redirect/tsconfig.spec.json
index 94be5ce7ec1..fd24a856df9 100644
--- a/apps/redirect/tsconfig.spec.json
+++ b/apps/redirect/tsconfig.spec.json
@@ -7,7 +7,7 @@
"moduleResolution": "bundler",
"isolatedModules": true
},
- "files": ["src/test-setup.ts"],
+ "files": ["../../shared/test-setup-base.ts"],
"include": ["**/*.spec.ts", "**/*.d.ts"],
"angularCompilerOptions": {
"extendedDiagnostics": {
diff --git a/apps/signage-manager/ngsw-config.json b/apps/signage-manager/ngsw-config.json
deleted file mode 100644
index 3b65a2681c1..00000000000
--- a/apps/signage-manager/ngsw-config.json
+++ /dev/null
@@ -1,43 +0,0 @@
-{
- "$schema": "../../node_modules/@angular/service-worker/config/schema.json",
- "index": "/index.html",
- "assetGroups": [
- {
- "name": "app",
- "installMode": "prefetch",
- "resources": {
- "files": [
- "/favicon.ico",
- "/index.html",
- "/manifest.webmanifest",
- "/*.css",
- "/*.js"
- ]
- }
- },
- {
- "name": "assets",
- "installMode": "lazy",
- "updateMode": "prefetch",
- "resources": {
- "files": [
- "/assets/**",
- "/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)"
- ]
- }
- }
- ],
- "dataGroups": [
- {
- "name": "s3",
- "urls": [
- "https://*.amazonaws.com/**/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)"
- ],
- "cacheConfig": {
- "strategy": "performance",
- "maxSize": 200,
- "maxAge": "7d"
- }
- }
- ]
-}
diff --git a/apps/signage-manager/project.json b/apps/signage-manager/project.json
index cea0e754cf4..84c47ee2c7c 100644
--- a/apps/signage-manager/project.json
+++ b/apps/signage-manager/project.json
@@ -65,7 +65,7 @@
"with": "apps/signage-manager/src/environments/settings.ts"
}
],
- "serviceWorker": "apps/signage-manager/ngsw-config.json"
+ "serviceWorker": "shared/ngsw-config.json"
},
"production": {
"fileReplacements": [
@@ -103,7 +103,7 @@
"maximumError": "10kb"
}
],
- "serviceWorker": "apps/signage-manager/ngsw-config.json"
+ "serviceWorker": "shared/ngsw-config.json"
}
}
},
diff --git a/apps/signage-manager/src/app/app.config.ts b/apps/signage-manager/src/app/app.config.ts
index dbd65a682cf..6d4a69788c2 100644
--- a/apps/signage-manager/src/app/app.config.ts
+++ b/apps/signage-manager/src/app/app.config.ts
@@ -12,12 +12,14 @@ import {
Routes,
withComponentInputBinding,
withHashLocation,
+ withNavigationErrorHandler,
} from '@angular/router';
import { provideServiceWorker } from '@angular/service-worker';
import {
LazySentryErrorHandler,
LocaleService,
registerActiveLocale,
+ reloadOnChunkLoadError,
} from '@placeos/common';
import {
AuthorisedUserGuard,
@@ -165,6 +167,7 @@ export const APP_CONFIG: ApplicationConfig = {
APP_ROUTES,
withHashLocation(),
withComponentInputBinding(),
+ withNavigationErrorHandler((e) => reloadOnChunkLoadError(e.error)),
),
{
provide: ErrorHandler,
diff --git a/apps/signage-manager/src/app/branding/branding.component.ts b/apps/signage-manager/src/app/branding/branding.component.ts
index 3b02a90a1b9..bc4c19cd17c 100644
--- a/apps/signage-manager/src/app/branding/branding.component.ts
+++ b/apps/signage-manager/src/app/branding/branding.component.ts
@@ -28,6 +28,7 @@ import {
} from '../image-gen/image-gen.types';
import { actionError } from '../image-gen/image-gen.util';
import { SignageContextService } from '../signage-context.service';
+import { uploadUrl } from '../signage-url.util';
import { brandEditingOn, canEditBrandKit } from './brand-access';
import { BRAND_FONTS, ensureBrandFont } from './brand-fonts';
@@ -495,7 +496,7 @@ export class BrandingComponent implements OnInit {
public logoUrl(slot: ImageGenLogoSlot) {
const id = this.logos()[slot];
- return id ? `/api/engine/v2/uploads/${encodeURIComponent(id)}/url` : '';
+ return id ? uploadUrl(id) : '';
}
public other(slot: ImageGenLogoSlot): ImageGenLogoSlot {
diff --git a/apps/signage-manager/src/app/displays/display-content.component.ts b/apps/signage-manager/src/app/displays/display-content.component.ts
index 775870a0fd7..cb0b1c1d0e1 100644
--- a/apps/signage-manager/src/app/displays/display-content.component.ts
+++ b/apps/signage-manager/src/app/displays/display-content.component.ts
@@ -1,18 +1,14 @@
import { Component, computed, inject, input } from '@angular/core';
-import { MatRippleModule } from '@angular/material/core';
-import { MatTooltipModule } from '@angular/material/tooltip';
-import { RouterLink } from '@angular/router';
import {
IconComponent,
LoadErrorComponent,
TranslatePipe,
} from '@placeos/components';
-import { SignagePlaylist } from '@placeos/ts-client';
import { SignagePlaylistService } from '../playlists/signage-playlist.service';
-import { PlaylistThumbnailComponent } from '../shared/playlist-thumbnail.component';
+import { AssignedPlaylistsPanelComponent } from '../shared/assigned-playlists-panel.component';
+import { LinkedItemRowComponent } from '../shared/linked-item-row.component';
import { TemplateMappingsComponent } from '../shared/template-mappings.component';
import { SignageContextService } from '../signage-context.service';
-import { playlistStatus } from '../signage-playlist.util';
import { DisplayScheduleComponent } from './display-schedule.component';
import { SignageDisplayService } from './signage-display.service';
@@ -56,209 +52,24 @@ import { SignageDisplayService } from './signage-display.service';
aria-labelledby="display-playlists-tab"
class="bg-base-100 border-base-300 min-h-0 flex-1 overflow-auto rounded-lg border"
>
-
-
- playlist_play
- {{
- 'SIGNAGE_MANAGER.PLAYLISTS_COUNT'
- | translate
- : {
- count: display_playlists()
- .length,
- }
- : display_playlists().length
- }}
-
- @if (can_update()) {
-
- }
-
-
- @if (display_playlists().length > 0) {
- @for (
- playlist of display_playlists();
- track playlist.id
- ) {
-
- }
- } @else if (
+
- {{ 'COMMON.LOADING' | translate }}
-
- } @else if (
+ "
+ [error]="
has_assigned_playlists() &&
playlists_error()
- ) {
-
- } @else {
-
-
playlist_remove
-
- {{
- 'SIGNAGE_MANAGER.NO_PLAYLISTS_DISPLAY'
- | translate
- }}
-
-
- }
-
+ "
+ [can_update]="can_update()"
+ (add)="addPlaylist()"
+ (remove)="removePlaylist($event)"
+ (retry)="reloadPlaylists()"
+ />
}
@@ -294,42 +105,15 @@ import { SignageDisplayService } from './signage-display.service';
zone of display_zones();
track zone.id
) {
-
+ [description]="zone.description"
+ />
}
} @else if (display_zones_loading()) {
('schedule');
public readonly selected_display = this._display_service.selected_display;
- public readonly playlist_approval_status =
- this._playlist_service.playlist_approval_status;
public readonly can_update = this._context.can_update;
public readonly display_playlists = computed(() =>
@@ -439,9 +219,7 @@ export class DisplayContentComponent {
if (display) this._display_service.addPlaylistToDisplay(display);
}
- public removePlaylist(event: Event, playlist_id: string) {
- event.preventDefault();
- event.stopPropagation();
+ public removePlaylist(playlist_id: string) {
const display = this.selected_display();
if (display)
this._display_service.removePlaylistFromDisplay(
@@ -449,12 +227,4 @@ export class DisplayContentComponent {
playlist_id,
);
}
-
- public getStatus(playlist: SignagePlaylist) {
- return playlistStatus(
- playlist,
- this.playlist_approval_status(),
- this._playlist_service.playlist_approval_requested_status(),
- );
- }
}
diff --git a/apps/signage-manager/src/app/displays/display-header.component.ts b/apps/signage-manager/src/app/displays/display-header.component.ts
index 1d2b57760c6..f1d31de6616 100644
--- a/apps/signage-manager/src/app/displays/display-header.component.ts
+++ b/apps/signage-manager/src/app/displays/display-header.component.ts
@@ -1,60 +1,22 @@
import { Component, inject } from '@angular/core';
-import { MatRippleModule } from '@angular/material/core';
-import { MatTooltipModule } from '@angular/material/tooltip';
import { Router } from '@angular/router';
-import { IconComponent, TranslatePipe } from '@placeos/components';
-import { GroupBreadcrumbsComponent } from '../shared/group-breadcrumbs.component';
+import { SectionHeaderComponent } from '../shared/section-header.component';
import { SignageContextService } from '../signage-context.service';
import { SignageDisplayService } from './signage-display.service';
@Component({
selector: 'display-header',
template: `
-
-
-
- {{ 'SIGNAGE_MANAGER.DISPLAYS_TITLE' | translate }}
-
-
-
- {{
- 'COMMON.ITEM_COUNT'
- | translate
- : { count: total_count() }
- : total_count()
- }}
-
-
-
-
-
- @if (can_create()) {
-
- }
-
+
`,
- imports: [
- MatRippleModule,
- IconComponent,
- TranslatePipe,
- GroupBreadcrumbsComponent,
- MatTooltipModule,
- ],
+ imports: [SectionHeaderComponent],
})
export class DisplayHeaderComponent {
private readonly _context = inject(SignageContextService);
diff --git a/apps/signage-manager/src/app/displays/display-list.component.ts b/apps/signage-manager/src/app/displays/display-list.component.ts
index 4a8503648c7..f8bf4b7fec5 100644
--- a/apps/signage-manager/src/app/displays/display-list.component.ts
+++ b/apps/signage-manager/src/app/displays/display-list.component.ts
@@ -1,6 +1,4 @@
-import { DatePipe } from '@angular/common';
import {
- afterRenderEffect,
Component,
DestroyRef,
ElementRef,
@@ -16,14 +14,17 @@ import { MatInputModule } from '@angular/material/input';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterLink } from '@angular/router';
import {
- DateFromPipe,
IconComponent,
LoadErrorComponent,
TranslatePipe,
} from '@placeos/components';
-import { isSameDay } from 'date-fns';
import { IntersectDirective } from '../shared/intersect.directive';
-import { isDisplayOnline } from './display-status.util';
+import { scrollSelectionIntoView } from '../shared/scroll-selection.util';
+import {
+ displayLastSeen,
+ displayStatusKey,
+ isDisplayOnline,
+} from './display-status.util';
import { SignageDisplayService } from './signage-display.service';
@Component({
@@ -184,30 +185,17 @@ export class DisplayListComponent {
// Ticks each minute so a display that stops checking in turns offline
// without a reload.
private readonly _now = signal(Date.now());
- private readonly _date_from = new DateFromPipe();
- private readonly _date = new DatePipe(inject(LOCALE_ID));
+ private readonly _locale = inject(LOCALE_ID);
constructor() {
const timer = setInterval(() => this._now.set(Date.now()), 60 * 1000);
inject(DestroyRef).onDestroy(() => clearInterval(timer));
- afterRenderEffect({
- earlyRead: () => {
- const selected_id = this.selected()?.id;
- if (!selected_id) return;
- const display_index = this.displays().findIndex(
- ({ id }) => id === selected_id,
- );
- return this._display_items()[display_index]?.nativeElement;
- },
- write: (selected_item) => {
- selected_item()?.scrollIntoView?.({
- behavior: 'instant',
- block: 'nearest',
- inline: 'nearest',
- });
- },
- });
+ scrollSelectionIntoView(
+ this.displays,
+ this._display_items,
+ this.selected,
+ );
}
public loadMore() {
@@ -224,26 +212,15 @@ export class DisplayListComponent {
/** Translation key for the status tooltip of a display */
public statusLabel(display: { signage_last_seen?: number }) {
- if (!display.signage_last_seen) {
- return 'SIGNAGE_MANAGER.DISPLAY_STATUS_NEVER_SEEN';
- }
- return this.isOnline(display)
- ? 'SIGNAGE_MANAGER.DISPLAY_STATUS_ONLINE'
- : 'SIGNAGE_MANAGER.DISPLAY_STATUS_OFFLINE';
+ return displayStatusKey(display.signage_last_seen, this._now());
}
- /**
- * When the display's player last checked in: minutes ago within the last
- * hour, the time earlier today, and the date and time before today.
- */
+ /** When the display's player last checked in */
public lastSeen(display: { signage_last_seen?: number }) {
- const now = this._now();
- if (!display.signage_last_seen) return '';
- const last_seen = display.signage_last_seen * 1000;
- if (now - last_seen < 60 * 60 * 1000) {
- return this._date_from.transform(last_seen);
- }
- const date_format = isSameDay(last_seen, now) ? 'shortTime' : 'short';
- return this._date.transform(last_seen, date_format) || '';
+ return displayLastSeen(
+ display.signage_last_seen,
+ this._now(),
+ this._locale,
+ );
}
}
diff --git a/apps/signage-manager/src/app/displays/display-status.util.ts b/apps/signage-manager/src/app/displays/display-status.util.ts
index 37a40b66353..97b22969f15 100644
--- a/apps/signage-manager/src/app/displays/display-status.util.ts
+++ b/apps/signage-manager/src/app/displays/display-status.util.ts
@@ -1,3 +1,7 @@
+import { formatDate } from '@angular/common';
+import { DateFromPipe } from '@placeos/components';
+import { isSameDay } from 'date-fns';
+
/** Minutes without a player check-in before a display counts as offline */
const DISPLAY_OFFLINE_MINUTES = 5;
@@ -15,3 +19,44 @@ export function isDisplayOnline(
Math.abs(now - last_seen * 1000) <= DISPLAY_OFFLINE_MINUTES * 60 * 1000
);
}
+
+/**
+ * Translation key of the status of a display. The keys take the last seen
+ * time as `time`.
+ * @param last_seen Last player check-in in Unix seconds, 0 or omitted if never seen
+ * @param now Current time in milliseconds
+ */
+export function displayStatusKey(
+ last_seen: number | undefined,
+ now = Date.now(),
+) {
+ if (!last_seen) return 'SIGNAGE_MANAGER.DISPLAY_STATUS_NEVER_SEEN';
+ return isDisplayOnline(last_seen, now)
+ ? 'SIGNAGE_MANAGER.DISPLAY_STATUS_ONLINE'
+ : 'SIGNAGE_MANAGER.DISPLAY_STATUS_OFFLINE';
+}
+
+const date_from = new DateFromPipe();
+
+/**
+ * When a display's player last checked in: relative within the last hour,
+ * the time earlier today, and the date and time before today. Empty when
+ * it was never seen.
+ * @param last_seen Last player check-in in Unix seconds
+ * @param now Current time in milliseconds
+ * @param locale Locale of the date and time
+ */
+export function displayLastSeen(
+ last_seen: number | undefined,
+ now: number,
+ locale: string,
+) {
+ if (!last_seen) return '';
+ const seen_at = last_seen * 1000;
+ if (now - seen_at < 60 * 60 * 1000) return date_from.transform(seen_at);
+ return formatDate(
+ seen_at,
+ isSameDay(seen_at, now) ? 'shortTime' : 'short',
+ locale,
+ );
+}
diff --git a/apps/signage-manager/src/app/displays/displays.component.ts b/apps/signage-manager/src/app/displays/displays.component.ts
index bc104b82968..45ad3e583bb 100644
--- a/apps/signage-manager/src/app/displays/displays.component.ts
+++ b/apps/signage-manager/src/app/displays/displays.component.ts
@@ -1,20 +1,19 @@
-import {
- Component,
- computed,
- effect,
- inject,
- input,
- signal,
-} from '@angular/core';
+import { Component, computed, inject, input } from '@angular/core';
import { MatRippleModule } from '@angular/material/core';
import { MatTooltipModule } from '@angular/material/tooltip';
-import { ActivatedRoute, Router } from '@angular/router';
+import { Router } from '@angular/router';
import { settingSignal } from '@placeos/common';
import { IconComponent, TranslatePipe } from '@placeos/components';
import { SignagePlaylistService } from '../playlists/signage-playlist.service';
import { NavFooterComponent } from '../shared/nav-footer.component';
import { NavSidebarComponent } from '../shared/nav-sidebar.component';
+import { routedTab } from '../shared/routed-tab.util';
+import {
+ SectionTab,
+ SectionTabsComponent,
+} from '../shared/section-tabs.component';
import { SignageContextService } from '../signage-context.service';
+import { signagePlayerUrl } from '../signage-url.util';
import { DisplayContentComponent } from './display-content.component';
import { DisplayHeaderComponent } from './display-header.component';
import { DisplayListComponent } from './display-list.component';
@@ -22,11 +21,9 @@ import { selectRoutedItem } from './routed-selection.util';
import { showSignageDisplay } from './signage-display';
import { SignageDisplayService } from './signage-display.service';
-const TAB_QUERY_PARAM = 'tab';
+type DisplayTab = 'schedule' | 'templates' | 'playlists' | 'zones';
-function parseDisplayTab(
- value: string | null,
-): 'schedule' | 'templates' | 'playlists' | 'zones' {
+function parseDisplayTab(value: string | null): DisplayTab {
if (value === 'templates' || value === 'playlists' || value === 'zones') {
return value;
}
@@ -146,155 +143,17 @@ function parseDisplayTab(
open_in_new
+ />
}