diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 6f12915ae32..b77d48b47d4 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -129,12 +129,12 @@ jobs: test: runs-on: ubuntu-latest needs: install_deps - if: ${{ fromJSON(needs.install_deps.outputs.affected_apps)[0] != null }} + if: ${{ fromJSON(needs.install_deps.outputs.affected_units)[0] != null }} strategy: matrix: - package: ${{ fromJSON(needs.install_deps.outputs.affected_apps) }} + package: ${{ fromJSON(needs.install_deps.outputs.affected_units) }} env: - jobCount: ${{ fromJSON(needs.install_deps.outputs.affected_apps).length }} + jobCount: ${{ fromJSON(needs.install_deps.outputs.affected_units).length }} configuration: ${{ needs.install_deps.outputs.configuration }} deploy_branch: ${{ needs.install_deps.outputs.deploy_branch }} short_sha: ${{ needs.install_deps.outputs.short_sha }} diff --git a/apps/concierge/project.json b/apps/concierge/project.json index 040808b534c..255771e9125 100644 --- a/apps/concierge/project.json +++ b/apps/concierge/project.json @@ -96,7 +96,7 @@ { "type": "anyComponentStyle", "maximumWarning": "6kb", - "maximumError": "10kb" + "maximumError": "16kb" } ], "serviceWorker": "apps/concierge/ngsw-config.json" diff --git a/apps/concierge/src/app/app.routes.ts b/apps/concierge/src/app/app.routes.ts index 223fbad884c..c53c4769e25 100644 --- a/apps/concierge/src/app/app.routes.ts +++ b/apps/concierge/src/app/app.routes.ts @@ -126,6 +126,14 @@ export const routes: Routes = [ canActivate: [AuthorisedUserGuard], canLoad: [AuthorisedUserGuard], }, + { + path: 'map-management', + title: 'Map Management', + loadChildren: () => + import('./map-manager/map-manager.routes').then((m) => m.ROUTES), + canActivate: [AuthorisedUserGuard], + canLoad: [AuthorisedUserGuard], + }, { path: 'zone-management', title: 'Zone Management', diff --git a/apps/concierge/src/app/level-manager/level-list.component.ts b/apps/concierge/src/app/level-manager/level-list.component.ts index d28b4a325c5..b02b323ae97 100644 --- a/apps/concierge/src/app/level-manager/level-list.component.ts +++ b/apps/concierge/src/app/level-manager/level-list.component.ts @@ -2,6 +2,7 @@ import { Clipboard } from '@angular/cdk/clipboard'; import { Component, inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { MatMenuModule } from '@angular/material/menu'; +import { Router } from '@angular/router'; import { i18n, notifySuccess } from '@placeos/common'; import { IconComponent, @@ -94,7 +95,9 @@ import { LevelManagementService } from './level-management.service'; >
map - Edit SVG map + {{ + 'APP.CONCIERGE.MAPS_EDIT' | translate + }}
- } @empty { -
- No SVG elements found. -
- } - - -
-
- @if (preview_url()) { -
- SVG map preview - @if (overlay_url()) { - - } -
- } @else { -
- image -

No SVG loaded.

-
- } -
-
- @if (selected_element(); as element) { -
-

- {{ element.label }} -

- - <{{ element.tag }}> - - @if ( - isElementEdited(element.editor_index) - ) { - - } -
-
-
- - - - -
-
- - - - - - -
-
- - - - - - -
-
- @if (element.can_edit_text) { -
- - - - -
- } - } @else { -
- touch_app -

- Select an element from the list to edit - its ID, colours, or text. -

-
- } -
-
- - } - - `, - styles: [ - ` - :host { - display: block; - } - - :host ::ng-deep fullscreen-modal-shell > div { - overflow: hidden; - } - - :host ::ng-deep fullscreen-modal-shell main { - min-height: 0; - overflow: hidden; - } - - label { - margin-bottom: 0.25rem; - font-size: 0.75rem; - font-weight: 500; - opacity: 0.75; - } - - .checker { - background-color: var(--base-100); - background-image: - linear-gradient( - 45deg, - var(--base-300) 25%, - transparent 25% - ), - linear-gradient( - -45deg, - var(--base-300) 25%, - transparent 25% - ), - linear-gradient( - 45deg, - transparent 75%, - var(--base-300) 75% - ), - linear-gradient( - -45deg, - transparent 75%, - var(--base-300) 75% - ); - background-size: 8px 8px; - background-position: - 0 0, - 0 4px, - 4px -4px, - -4px 0; - } - - .colour-stack { - position: relative; - width: 2.25rem; - height: 2.25rem; - } - - .colour-tile { - position: absolute; - display: block; - height: 1.5rem; - width: 1.5rem; - overflow: hidden; - border-width: 1px; - border-radius: 0.25rem; - box-shadow: 0 1px 2px rgb(0 0 0 / 0.16); - } - - .stroke-tile { - top: 0; - right: 0; - } - - .fill-tile { - bottom: 0; - left: 0; - } - - .colour-picker-input { - position: absolute; - width: 1px; - height: 1px; - opacity: 0; - pointer-events: none; - } - - .map-preview { - position: relative; - width: 100%; - height: 100%; - min-width: 0; - min-height: 0; - filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)); - } - - .map-preview > img { - position: absolute; - inset: 0; - display: block; - width: 100%; - height: 100%; - min-width: 0; - min-height: 0; - object-fit: contain; - } - - .canvas { - background-color: var(--base-100); - background-image: - linear-gradient( - 45deg, - var(--base-200) 25%, - transparent 25% - ), - linear-gradient( - -45deg, - var(--base-200) 25%, - transparent 25% - ), - linear-gradient( - 45deg, - transparent 75%, - var(--base-200) 75% - ), - linear-gradient( - -45deg, - transparent 75%, - var(--base-200) 75% - ); - background-size: 22px 22px; - background-position: - 0 0, - 0 11px, - 11px -11px, - -11px 0; - } - `, - ], - imports: [ - FormsModule, - FullscreenModalShellComponent, - IconComponent, - MatFormFieldModule, - MatInputModule, - MatRippleModule, - ], -}) -export class LevelMapEditorModalComponent implements OnInit, OnDestroy { - private readonly _uploads = inject(UploadsService); - private readonly _level = inject(MAT_DIALOG_DATA); - private readonly _dialog_ref = - inject>(MatDialogRef); - - private _document: Document | null = null; - private _preview_object_url = ''; - private _overlay_object_url = ''; - private _style_rules: SvgStyleRule[] = []; - private _original_elements = new Map(); - - public readonly loading = signal(''); - public readonly error = signal(''); - public readonly elements = signal([]); - public readonly filter = signal(''); - public readonly show_only_ids = signal(false); - public readonly selected_index = signal(-1); - public readonly hovered_index = signal(-1); - public readonly selected_id = signal(''); - public readonly selected_fill = signal(''); - public readonly selected_stroke = signal(''); - public readonly selected_text = signal(''); - public readonly preview_url = signal(''); - public readonly overlay_url = signal(''); - public readonly svg_markup = signal(''); - public readonly dirty = signal(false); - public readonly edited_elements = signal>(new Set()); - - public readonly filtered_elements = computed(() => { - const filter_text = this.filter().trim().toLowerCase(); - const elements = this.show_only_ids() - ? this.elements().filter((element) => !!element.id) - : this.elements(); - if (!filter_text) return elements; - return elements.filter((element) => - [ - element.id, - element.tag, - element.text, - element.fill, - element.stroke, - ] - .join(' ') - .toLowerCase() - .includes(filter_text), - ); - }); - - public readonly selected_element = computed(() => - this.elements().find( - (element) => element.editor_index === this.selected_index(), - ), - ); - - public readonly outlined_indices = computed(() => { - const selected_index = this.selected_index(); - const hovered_index = this.hovered_index(); - const indices: number[] = []; - if (selected_index >= 0) indices.push(selected_index); - if (hovered_index >= 0 && hovered_index !== selected_index) { - indices.push(hovered_index); - } - return indices; - }); - - public readonly can_save = computed( - () => !!this.svg_markup() && this.dirty() && !this.loading(), - ); - - public async ngOnInit() { - if (!this._level?.map_id) { - this.error.set('This level does not have an SVG map URL set.'); - return; - } - this.loading.set('Loading SVG map...'); - try { - const details = await getMapDetails(this._level.map_id); - this._loadSvg(details.raw_data); - } catch (error) { - this.error.set( - `Failed to load the SVG map. ${this._errorMessage(error)}`, - ); - } finally { - this.loading.set(''); - } - } - - public ngOnDestroy() { - this._revokePreviewUrl(); - } - - public selectElement(editor_index: number) { - if (!this._setSelectedElement(editor_index)) return; - this._refreshOverlay(); - } - - public hoverElement(editor_index: number) { - if (this.hovered_index() === editor_index) return; - this.hovered_index.set(editor_index); - this._refreshOverlay(); - } - - public clearHoveredElement(editor_index: number) { - if (this.hovered_index() !== editor_index) return; - this.hovered_index.set(-1); - this._refreshOverlay(); - } - - public updateSelectedId(value: string) { - this.selected_id.set(value); - this.applySelectedChanges(); - } - - public updateSelectedFill(value: string) { - this.selected_fill.set(value); - this.applySelectedChanges(); - } - - public updateSelectedStroke(value: string) { - this.selected_stroke.set(value); - this.applySelectedChanges(); - } - - public updateSelectedText(value: string) { - this.selected_text.set(value); - this.applySelectedChanges(); - } - - public applySelectedChanges() { - const editor_index = this.selected_index(); - const element = this._elementAtIndex(editor_index); - if (!element) return; - this._setAttribute(element, 'id', this.selected_id().trim()); - this._setColourAttribute(element, 'fill', this.selected_fill().trim()); - this._setColourAttribute( - element, - 'stroke', - this.selected_stroke().trim(), - ); - if (TEXT_TAGS.has(element.tagName.toLowerCase())) { - element.textContent = this.selected_text(); - } - this._refreshElements(); - this.selected_index.set(editor_index); - this.edited_elements.update( - (indices) => new Set([...indices, editor_index]), - ); - this.dirty.set(true); - this._refreshPreview(); - } - - public colourPreview(value: string) { - const colour = (value || '').replace('!important', '').trim(); - return colour && - typeof CSS !== 'undefined' && - CSS.supports('color', colour) - ? colour - : 'transparent'; - } - - public colourPickerValue(value: string) { - const colour = (value || '').replace('!important', '').trim(); - return /^#[0-9a-f]{6}$/i.test(colour) ? colour : '#000000'; - } - - public isElementEdited(editor_index: number) { - return this.edited_elements().has(editor_index); - } - - public resetElement(editor_index: number) { - const element = this._elementAtIndex(editor_index); - const original = this._original_elements.get(editor_index); - if (!element || !original) return; - element.replaceWith(original.cloneNode(true)); - this.edited_elements.update((indices) => { - const next = new Set(indices); - next.delete(editor_index); - return next; - }); - this._refreshElements(); - this.hovered_index.set(-1); - this._setSelectedElement(editor_index); - this.dirty.set(this.edited_elements().size > 0); - this._refreshPreview(); - } - - public async save() { - if (!this._document || !this.svg_markup()) return; - const duplicate_id = this._duplicateId(); - if (duplicate_id) { - notifyError(`Duplicate SVG element ID "${duplicate_id}"`); - return; - } - this.loading.set('Uploading edited SVG map...'); - try { - const file = new File([this.svg_markup()], this._fileName(), { - type: 'image/svg+xml', - }); - const upload_id = await this._uploads.uploadFileToCompletion(file); - const map_id = `/api/engine/v2/uploads/${encodeURIComponent( - upload_id, - )}/url`; - this.loading.set('Updating level map URL...'); - const level_name = - this._level.name || - `LEVEL ${authority().description} ${ - this._level.display_name || this._level.id - }`; - const response = await updateZone(this._level.id, { - id: this._level.id, - display_name: this._level.display_name, - parent_id: this._level.parent_id, - map_id, - name: level_name, - tags: this._level.tags?.length ? this._level.tags : ['level'], - }); - notifySuccess('Successfully updated level SVG map.'); - this._dialog_ref.close({ - ...this._level, - ...response, - map_id, - tags: response?.tags?.length - ? response.tags - : this._level.tags?.length - ? this._level.tags - : ['level'], - }); - } catch (error) { - notifyError( - `Failed to save the SVG map. ${this._errorMessage(error)}`, - ); - } finally { - this.loading.set(''); - } - } - - private _loadSvg(svg_markup: string) { - const parser = new DOMParser(); - const doc = parser.parseFromString(svg_markup, 'image/svg+xml'); - const parser_error = doc.querySelector('parsererror'); - const svg = doc.querySelector('svg'); - if (parser_error || !svg) throw new Error('Invalid SVG document.'); - this._document = doc; - this._style_rules = this._svgStyleRules(doc); - this._original_elements = this._originalElementMap(doc); - this.edited_elements.set(new Set()); - this.error.set(''); - this._refreshElements(); - this._setSelectedElement(this.elements()[0]?.editor_index ?? -1); - this.dirty.set(false); - this._refreshPreview(); - } - - private _refreshElements() { - const svg = this._document?.querySelector('svg'); - if (!svg) { - this.elements.set([]); - return; - } - const elements = Array.from(svg.querySelectorAll('*')) - .map((element, index) => ({ element, index })) - .filter(({ element }) => this._isEditableElement(element)) - .filter(({ element }) => this._isVisibleElement(element)) - .map(({ element, index }) => this._elementDetails(element, index)); - this.elements.set(elements); - } - - private _setSelectedElement(editor_index: number) { - const element = this._elementAtIndex(editor_index); - if (!element) return false; - const details = this._elementDetails(element, editor_index); - this.selected_index.set(editor_index); - this.selected_id.set(details.id); - this.selected_fill.set(details.fill); - this.selected_stroke.set(details.stroke); - this.selected_text.set(details.text); - return true; - } - - private _originalElementMap(doc: Document) { - const svg = doc.querySelector('svg'); - const elements = new Map(); - if (!svg) return elements; - Array.from(svg.querySelectorAll('*')).forEach((element, index) => - elements.set(index, element.cloneNode(true) as Element), - ); - return elements; - } - - private _refreshPreview() { - const markup = this._serialiseSvg(); - this.svg_markup.set(markup); - this._revokeMapPreviewUrl(); - this._preview_object_url = URL.createObjectURL( - new Blob([markup], { type: 'image/svg+xml' }), - ); - this.preview_url.set(this._preview_object_url); - this._refreshOverlay(); - } - - private _refreshOverlay() { - this._revokeOverlayUrl(); - const overlay_markup = this._serialiseSelectionOverlay(); - if (!overlay_markup) { - this.overlay_url.set(''); - return; - } - this._overlay_object_url = URL.createObjectURL( - new Blob([overlay_markup], { type: 'image/svg+xml' }), - ); - this.overlay_url.set(this._overlay_object_url); - } - - private _serialiseSvg() { - const svg = this._document?.querySelector('svg'); - if (!svg) return ''; - return new XMLSerializer().serializeToString(svg); - } - - private _serialiseSelectionOverlay() { - const svg = this._document?.querySelector('svg'); - const outlines = this.outlined_indices(); - if (!svg || !outlines.length) return ''; - const overlay_doc = document.implementation.createDocument( - SVG_NS, - 'svg', - null, - ); - const overlay_svg = overlay_doc.documentElement; - this._copySvgViewport(svg, overlay_svg); - overlay_svg.setAttribute('pointer-events', 'none'); - for (const editor_index of outlines) { - const selected_element = this._elementAtIndex(editor_index); - if (!selected_element) continue; - const overlay_element = overlay_doc.importNode( - this._selectedOverlayElement(selected_element), - true, - ) as Element; - this._highlightElement( - overlay_element, - this._outlineColour(editor_index), - ); - overlay_svg.appendChild(overlay_element); - } - return new XMLSerializer().serializeToString(overlay_svg); - } - - private _outlineColour(editor_index: number) { - return editor_index === this.hovered_index() - ? HOVERED_OUTLINE_COLOUR - : SELECTED_OUTLINE_COLOUR; - } - - private _revokePreviewUrl() { - this._revokeMapPreviewUrl(); - this._revokeOverlayUrl(); - } - - private _revokeMapPreviewUrl() { - if (this._preview_object_url) { - URL.revokeObjectURL(this._preview_object_url); - this._preview_object_url = ''; - } - this.preview_url.set(''); - } - - private _revokeOverlayUrl() { - if (this._overlay_object_url) { - URL.revokeObjectURL(this._overlay_object_url); - this._overlay_object_url = ''; - } - this.overlay_url.set(''); - } - - private _elementAtIndex(editor_index: number) { - const svg = this._document?.querySelector('svg'); - if (!svg || editor_index < 0) return null; - return Array.from(svg.querySelectorAll('*'))[editor_index] || null; - } - - private _elementDetails( - element: Element, - editor_index: number, - ): SvgMapElement { - const tag = element.tagName.toLowerCase(); - const can_edit_text = TEXT_TAGS.has(tag); - const text = can_edit_text ? (element.textContent || '').trim() : ''; - const id = element.getAttribute('id') || ''; - return { - editor_index, - tag, - id, - fill: this._visibleColourValue(element, 'fill'), - stroke: this._visibleColourValue(element, 'stroke'), - text, - can_edit_text, - label: id || text || `${tag} ${editor_index + 1}`, - }; - } - - private _isEditableElement(element: Element) { - const tag = element.tagName.toLowerCase(); - return ( - EDITABLE_TAGS.has(tag) || - !!element.getAttribute('id') || - !!this._colourAttribute(element, 'fill') || - !!this._colourAttribute(element, 'stroke') - ); - } - - private _isVisibleElement(element: Element): boolean { - if (this._hasHiddenAncestor(element)) return false; - const tag = element.tagName.toLowerCase(); - if (NON_RENDERED_TAGS.has(tag)) return false; - if (CONTAINER_TAGS.has(tag)) { - return Array.from(element.children).some((child) => - this._isVisibleElement(child), - ); - } - if (TEXT_TAGS.has(tag)) { - return ( - !!(element.textContent || '').trim() && - (this._hasVisibleFill(element) || - this._hasVisibleStroke(element)) - ); - } - if (LINE_TAGS.has(tag)) return this._hasVisibleStroke(element); - if (REFERENCE_TAGS.has(tag)) return this._hasReference(element); - return this._hasVisibleFill(element) || this._hasVisibleStroke(element); - } - - private _hasHiddenAncestor(element: Element) { - let current: Element | null = element; - while (current) { - const tag = current.tagName.toLowerCase(); - if (NON_RENDERED_TAGS.has(tag)) return true; - if ( - current.hasAttribute('hidden') || - this._styleValue(current, 'display') === 'none' || - ['hidden', 'collapse'].includes( - this._styleValue(current, 'visibility'), - ) || - this._numberValue(this._styleValue(current, 'opacity')) === 0 - ) { - return true; - } - current = current.parentElement; - } - return false; - } - - private _hasVisibleFill(element: Element) { - const fill = this._inheritedStyleValue(element, 'fill', 'black'); - const fill_opacity = this._numberValue( - this._inheritedStyleValue(element, 'fill-opacity', '1'), - ); - return ( - !this._isTransparentColour(fill || 'black') && fill_opacity !== 0 - ); - } - - private _hasVisibleStroke(element: Element) { - const stroke = this._inheritedStyleValue(element, 'stroke', ''); - const stroke_opacity = this._numberValue( - this._inheritedStyleValue(element, 'stroke-opacity', '1'), - ); - const stroke_width = this._numberValue( - this._inheritedStyleValue(element, 'stroke-width', '1'), - ); - return ( - !!stroke && - !this._isTransparentColour(stroke) && - stroke_opacity !== 0 && - stroke_width !== 0 - ); - } - - private _hasReference(element: Element) { - return ( - element.hasAttribute('href') || - element.hasAttribute('xlink:href') || - element.hasAttributeNS('http://www.w3.org/1999/xlink', 'href') - ); - } - - private _colourAttribute(element: Element, name: 'fill' | 'stroke') { - return ( - element.getAttribute(name) || - (element as SVGElement).style?.getPropertyValue(name) || - '' - ); - } - - private _visibleColourValue(element: Element, name: 'fill' | 'stroke') { - const fallback = name === 'fill' ? 'black' : ''; - return this._inheritedStyleValue(element, name, fallback); - } - - private _styleValue(element: Element, name: string) { - const inline_value = (element as SVGElement).style?.getPropertyValue( - name, - ); - if (inline_value) return this._cleanStyleValue(inline_value); - const css_value = this._cssStyleValue(element, name); - if (css_value) return css_value; - return this._cleanStyleValue(element.getAttribute(name) || '') || ''; - } - - private _cssStyleValue(element: Element, name: string) { - let value = ''; - let specificity = -1; - for (const rule of this._style_rules) { - const style_value = rule.styles[name]; - if (!style_value) continue; - for (const selector of rule.selectors) { - if (!this._matchesSelector(element, selector)) continue; - const next_specificity = this._selectorSpecificity(selector); - if (next_specificity >= specificity) { - value = style_value; - specificity = next_specificity; - } - } - } - return value; - } - - private _svgStyleRules(doc: Document): SvgStyleRule[] { - const rules: SvgStyleRule[] = []; - for (const style of Array.from(doc.querySelectorAll('style'))) { - const css = (style.textContent || '').replace( - /\/\*[\s\S]*?\*\//g, - '', - ); - const rule_regex = /([^{}@]+)\{([^{}]+)\}/g; - let match: RegExpExecArray | null; - while ((match = rule_regex.exec(css))) { - const selectors = match[1] - .split(',') - .map((_) => _.trim()) - .filter((_) => !!_); - const styles = match[2] - .split(';') - .map((_) => _.trim()) - .filter((_) => _.includes(':')) - .reduce( - (map, declaration) => { - const [key, ...parts] = declaration.split(':'); - const value = this._cleanStyleValue( - parts.join(':'), - ); - if (key.trim() && value) { - map[key.trim()] = value; - } - return map; - }, - {} as Record, - ); - if (selectors.length && Object.keys(styles).length) { - rules.push({ selectors, styles }); - } - } - } - return rules; - } - - private _matchesSelector(element: Element, selector: string) { - try { - return element.matches(selector); - } catch { - return false; - } - } - - private _selectorSpecificity(selector: string) { - const id_count = selector.match(/#[\w-]+/g)?.length || 0; - const class_count = - selector.match(/(\.[\w-]+|\[[^\]]+\]|:[\w-]+)/g)?.length || 0; - const tag_count = - selector - .replace(/#[\w-]+/g, ' ') - .replace(/(\.[\w-]+|\[[^\]]+\]|:[\w-]+)/g, ' ') - .split(/[\s>+~]+/) - .filter((_) => !!_.trim() && _.trim() !== '*').length || 0; - return id_count * 100 + class_count * 10 + tag_count; - } - - private _cleanStyleValue(value: string) { - return value.replace('!important', '').trim(); - } - - private _inheritedStyleValue( - element: Element, - name: string, - fallback: string, - ) { - let current: Element | null = element; - while (current) { - const value = this._styleValue(current, name); - if (value) return value; - current = current.parentElement; - } - return fallback; - } - - private _numberValue(value: string) { - if (!value) return null; - const number = parseFloat(value); - return Number.isFinite(number) ? number : null; - } - - private _isTransparentColour(value: string) { - const colour = value.trim().toLowerCase(); - return ( - !colour || - colour === 'none' || - colour === 'transparent' || - colour === 'rgba(0,0,0,0)' || - colour === 'rgba(0, 0, 0, 0)' - ); - } - - private _copySvgViewport(source: SVGSVGElement, target: Element) { - target.setAttribute('xmlns', SVG_NS); - for (const attribute of [ - 'viewBox', - 'width', - 'height', - 'preserveAspectRatio', - ]) { - const value = source.getAttribute(attribute); - if (value) target.setAttribute(attribute, value); - } - if (!target.getAttribute('viewBox')) { - const width = source.getAttribute('width') || '100%'; - const height = source.getAttribute('height') || '100%'; - target.setAttribute('width', width); - target.setAttribute('height', height); - } - } - - private _selectedOverlayElement(element: Element) { - let clone = element.cloneNode(true) as Element; - let parent = element.parentElement; - while (parent && parent.tagName.toLowerCase() !== 'svg') { - const parent_clone = parent.cloneNode(false) as Element; - parent_clone.appendChild(clone); - clone = parent_clone; - parent = parent.parentElement; - } - return clone; - } - - private _highlightElement(element: Element, colour: string) { - this._stripIds(element); - const targets = [element, ...Array.from(element.querySelectorAll('*'))]; - for (const target of targets) { - if (target.tagName === 'title' || target.tagName === 'desc') { - continue; - } - target.setAttribute('data-map-editor-highlight', 'true'); - target.setAttribute('fill', 'none'); - target.setAttribute('stroke', colour); - target.setAttribute('stroke-width', '3'); - target.setAttribute('vector-effect', 'non-scaling-stroke'); - target.setAttribute('pointer-events', 'none'); - target.setAttribute('opacity', '1'); - target.removeAttribute('display'); - target.removeAttribute('visibility'); - const style = (target as SVGElement).style; - style?.setProperty('fill', 'none', 'important'); - style?.setProperty('stroke', colour, 'important'); - style?.setProperty('stroke-width', '3', 'important'); - style?.setProperty( - 'vector-effect', - 'non-scaling-stroke', - 'important', - ); - style?.setProperty('pointer-events', 'none', 'important'); - style?.setProperty('opacity', '1', 'important'); - style?.removeProperty('display'); - style?.removeProperty('visibility'); - } - const title = element.ownerDocument.createElementNS(SVG_NS, 'title'); - title.textContent = 'Selected map element'; - element.insertBefore(title, element.firstChild); - } - - private _stripIds(element: Element) { - element.removeAttribute('id'); - for (const child of Array.from(element.querySelectorAll('[id]'))) { - child.removeAttribute('id'); - } - } - - private _setAttribute(element: Element, name: string, value: string) { - if (value) element.setAttribute(name, value); - else element.removeAttribute(name); - } - - private _setColourAttribute( - element: Element, - name: 'fill' | 'stroke', - value: string, - ) { - if (value) { - element.setAttribute(name, value); - (element as SVGElement).style?.setProperty(name, value); - } else { - element.removeAttribute(name); - (element as SVGElement).style?.removeProperty(name); - } - } - - private _duplicateId() { - const svg = this._document?.querySelector('svg'); - if (!svg) return ''; - const seen = new Set(); - for (const element of Array.from(svg.querySelectorAll('[id]'))) { - const id = element.getAttribute('id') || ''; - if (!id) continue; - if (seen.has(id)) return id; - seen.add(id); - } - return ''; - } - - private _fileName() { - const name = - this._level.display_name || this._level.name || this._level.id; - const slug = - name - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, '') || 'level-map'; - return `${slug}.svg`; - } - - private _errorMessage(error: any) { - return error?.message || error?.error || `${error || ''}`; - } -} diff --git a/apps/concierge/src/app/map-manager/map-editor-page.component.ts b/apps/concierge/src/app/map-manager/map-editor-page.component.ts new file mode 100644 index 00000000000..d6612d17f3e --- /dev/null +++ b/apps/concierge/src/app/map-manager/map-editor-page.component.ts @@ -0,0 +1,466 @@ +import { + Component, + computed, + DestroyRef, + effect, + inject, + OnInit, + signal, + viewChild, +} from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { ActivatedRoute, RouterModule } from '@angular/router'; +import { + BuildingLevel, + i18n, + notifyError, + notifyInfo, + notifySuccess, + notifyWarn, + OrganisationService, +} from '@placeos/common'; +import { + IconComponent, + openConfirmModal, + TranslatePipe, +} from '@placeos/components'; +import { + BrowserRenderCheck, + collectProtectedIds, + DEFAULT_AI_STRINGS, + DEFAULT_MAP_EDITOR_STRINGS, + IndexedDbDraftStore, + LiveGeometryProvider, + MapDocument, + MapEditorAiPanelComponent, + MapEditorAiStringsOverride, + MapEditorComponent, + MapEditorNotifier, + MapEditorSession, + MapEditorStrings, + NotifyAction, + NotifyKind, + UploadPort, +} from '@placeos/map-editor'; +import { HasUnsavedChanges } from '../ui/unsaved-changes.guard'; +import { MapManagementService } from './map-management.service'; + +/** The lib's English strings looked up as MAP_EDITOR.* locale keys. */ +export function mapEditorStrings(): MapEditorStrings { + const strings = { ...DEFAULT_MAP_EDITOR_STRINGS }; + for (const key of Object.keys(strings) as (keyof MapEditorStrings)[]) { + const locale_key = `MAP_EDITOR.${key.toUpperCase()}`; + const value = i18n(locale_key); + if (value && value !== locale_key) strings[key] = value; + } + return strings; +} + +/** + * The AI panel's English strings looked up as MAP_EDITOR.AI.* locale keys; a + * group such as `reasons` reads MAP_EDITOR.AI.REASONS.*. + */ +export function mapEditorAiStrings( + lookup: (key: string) => string = i18n, +): MapEditorAiStringsOverride { + const strings: Record> = {}; + for (const [name, value] of Object.entries(DEFAULT_AI_STRINGS)) { + const prefix = `MAP_EDITOR.AI.${name.toUpperCase()}`; + if (typeof value === 'string') { + strings[name] = localised(lookup, prefix, value); + continue; + } + const group: Record = {}; + for (const [key, text] of Object.entries( + value as Record, + )) { + group[key] = localised( + lookup, + `${prefix}.${key.toUpperCase()}`, + text, + ); + } + strings[name] = group; + } + return strings as MapEditorAiStringsOverride; +} + +function localised( + lookup: (key: string) => string, + key: string, + fallback: string, +): string { + const value = lookup(key); + return value && value !== key ? value : fallback; +} + +@Component({ + selector: '[app-map-editor-page]', + host: { + '(window:beforeunload)': 'onBeforeUnload($event)', + }, + template: ` +
+ + arrow_back + +
+

+ {{ level()?.display_name || level()?.name || level_id() }} +

+
+ @if (building()) { + {{ building()?.display_name || building()?.name }} + · + } + {{ 'APP.CONCIERGE.MAPS_SUBTITLE' | translate }} +
+
+ @if (bindings_incomplete()) { + + link_off + {{ 'APP.CONCIERGE.MAPS_BINDINGS_INCOMPLETE' | translate }} + + } + @if (changed_elsewhere()) { + + sync_problem + {{ 'APP.CONCIERGE.MAPS_CHANGED_ELSEWHERE' | translate }} + + } +
+ @if (session()) { +

+ + {{ dirty() ? strings.unsaved : strings.saved }} +

+ } + + + +
+
+ @if (error()) { +
+ error +

{{ error() }}

+
+ } @else if (session(); as editor_session) { + + @if (!ai_hidden()) { + + } + + } @else { +
+ {{ loading() }} +
+ } + `, + styles: [ + ` + :host { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + background-color: var(--base-100); + } + `, + ], + imports: [ + RouterModule, + MatRippleModule, + MatTooltipModule, + IconComponent, + TranslatePipe, + MapEditorComponent, + MapEditorAiPanelComponent, + ], +}) +export class MapEditorPageComponent implements OnInit, HasUnsavedChanges { + private readonly _route = inject(ActivatedRoute); + private readonly _org = inject(OrganisationService); + private readonly _dialog = inject(MatDialog); + private readonly _manager = inject(MapManagementService); + private readonly _destroy = inject(DestroyRef); + + public readonly level_id = signal(''); + public readonly level = signal(null); + public readonly session = signal(null); + public readonly loading = signal(''); + public readonly saving = signal(''); + public readonly error = signal(''); + public readonly failed_sources = signal([]); + public readonly changed_elsewhere = signal(false); + /** Set once the server answered that it has no map AI; drops the panel. */ + public readonly ai_hidden = signal(false); + + public readonly building = computed(() => + this._manager.building(this.level() || undefined), + ); + public readonly bindings_incomplete = computed( + () => this.failed_sources().length > 0, + ); + public readonly dirty = computed(() => this.session()?.dirty() ?? false); + + public readonly strings = mapEditorStrings(); + public readonly ai_strings = mapEditorAiStrings(); + public readonly notifier: MapEditorNotifier = { + confirm: (title, message, confirm_text) => + this._confirm(title, message, confirm_text), + notify: (kind, message, action) => this._notify(kind, message, action), + }; + public readonly upload_port: UploadPort = (blob, name) => + this._manager.uploadMapSnapshot(blob, name); + + private readonly _ai_panel = viewChild(MapEditorAiPanelComponent); + private _loaded_map_id = ''; + + constructor() { + this._destroy.onDestroy(() => this.session()?.dispose()); + effect(() => { + if (this._ai_panel()?.hidden()) this.ai_hidden.set(true); + }); + } + + public hasUnsavedChanges(): boolean { + return this.dirty(); + } + + /** Asks the browser to confirm leaving only while the map has unsaved changes. */ + public onBeforeUnload(event: BeforeUnloadEvent): void { + if (this.dirty()) event.preventDefault(); + } + + public async ngOnInit() { + this.loading.set(i18n('APP.CONCIERGE.MAPS_LOADING')); + this.level_id.set(this._route.snapshot.paramMap.get('level_id') || ''); + await this._org.waitUntilInitialised(); + const level = this._manager.level(this.level_id()); + if (!level) { + this.error.set(i18n('APP.CONCIERGE.MAPS_NO_LEVEL')); + return; + } + this.level.set(level); + if (!level.map_id) { + this.error.set(i18n('APP.CONCIERGE.MAPS_NO_MAP')); + return; + } + this._loaded_map_id = level.map_id; + try { + await this._load(level); + } catch (error) { + this.error.set( + `${i18n('APP.CONCIERGE.MAPS_LOAD_ERROR')} ${errorText(error)}`, + ); + } finally { + this.loading.set(''); + } + } + + public openStudio() { + this._manager.openStudio(this.level_id()); + } + + public async discard() { + const session = this.session(); + if (!session || !session.dirty()) return; + const ok = await this._confirm( + i18n('APP.CONCIERGE.MAPS_DISCARD_TITLE'), + i18n('APP.CONCIERGE.MAPS_DISCARD_MSG'), + i18n('APP.CONCIERGE.MAPS_DISCARD_CONFIRM'), + ); + if (!ok) return; + session.revert(this.strings.undo); + await session.clearDraft(); + } + + public async save() { + const session = this.session(); + const level = this.level(); + if (!session || !level || !session.dirty() || this.saving()) return; + this.saving.set(i18n('APP.CONCIERGE.MAPS_SAVING')); + try { + const current_map_id = await this._manager + .currentMapId(level.id) + .catch(() => this._loaded_map_id); + if (current_map_id !== this._loaded_map_id) { + this.changed_elsewhere.set(true); + const ok = await this._confirm( + i18n('APP.CONCIERGE.MAPS_CHANGED_ELSEWHERE_TITLE'), + i18n('APP.CONCIERGE.MAPS_CHANGED_ELSEWHERE_MSG'), + i18n('APP.CONCIERGE.MAPS_CHANGED_ELSEWHERE_CONFIRM'), + ); + if (!ok) return; + } + const markup = session.doc.serialise(); + const render = await session.render_check.check(markup); + if (!render.ok) { + notifyError( + `${i18n('APP.CONCIERGE.MAPS_RENDER_ERROR')} ${render.error || ''}`, + ); + return; + } + const saved = await this._manager.saveLevelMap( + level, + markup, + session.undo_label() || i18n('APP.CONCIERGE.MAPS_SUMMARY'), + ); + this._loaded_map_id = saved.map_id; + this.level.set(this._manager.level(level.id) || level); + this.changed_elsewhere.set(false); + session.markSaved(); + await session.clearDraft(); + notifySuccess(i18n('APP.CONCIERGE.MAPS_SAVED')); + } catch (error) { + notifyError( + `${i18n('APP.CONCIERGE.MAPS_SAVE_ERROR')} ${errorText(error)}`, + ); + } finally { + this.saving.set(''); + } + } + + private async _load(level: BuildingLevel) { + const [text, protected_ids] = await Promise.all([ + this._manager.loadMapText(level), + collectProtectedIds(this._manager.protectedIdSources(level)), + ]); + this.failed_sources.set(protected_ids.failed_sources); + let doc: MapDocument | null = null; + const geometry = new LiveGeometryProvider( + () => (doc as MapDocument).root, + ); + doc = MapDocument.load(text, { geometry, protected: protected_ids }); + const session = new MapEditorSession(doc, { + zone_id: level.id, + source_text: text, + geometry, + protected: protected_ids, + render_check: new BrowserRenderCheck(), + draft_store: IndexedDbDraftStore.isAvailable() + ? new IndexedDbDraftStore() + : null, + }); + this.session.set(session); + const draft = await session.loadDraft().catch(() => null); + if (draft && draft !== session.doc.serialise()) { + const ok = await this._confirm( + i18n('APP.CONCIERGE.MAPS_DRAFT_TITLE'), + i18n('APP.CONCIERGE.MAPS_DRAFT_MSG'), + i18n('APP.CONCIERGE.MAPS_DRAFT_RESTORE'), + ); + if (ok) { + try { + session.restore( + draft, + i18n('APP.CONCIERGE.MAPS_DRAFT_LABEL'), + ); + } catch (error) { + notifyWarn( + `${i18n('APP.CONCIERGE.MAPS_DRAFT_ERROR')} ${errorText(error)}`, + ); + await session.clearDraft(); + } + } else { + await session.clearDraft(); + } + } + } + + private async _confirm( + title: string, + content: string, + confirm_text: string, + ): Promise { + const ref = await openConfirmModal( + { title, content, confirm_text, icon: { content: 'warning' } }, + this._dialog, + ); + ref.close(); + return ref.reason === 'done'; + } + + private _notify(kind: NotifyKind, message: string, action?: NotifyAction) { + const label = action?.label; + const run = action?.run; + switch (kind) { + case 'success': + notifySuccess(message, label, run); + break; + case 'error': + notifyError(message, label, run); + break; + case 'warning': + notifyWarn(message, label, run); + break; + default: + notifyInfo(message, label, run); + } + } +} + +function errorText(error: unknown): string { + const detail = error as { message?: string; error?: string } | null; + return detail?.message || detail?.error || `${error || ''}`; +} diff --git a/apps/concierge/src/app/map-manager/map-list.utilities.ts b/apps/concierge/src/app/map-manager/map-list.utilities.ts new file mode 100644 index 00000000000..aa425370bb5 --- /dev/null +++ b/apps/concierge/src/app/map-manager/map-list.utilities.ts @@ -0,0 +1,159 @@ +import { Building, BuildingLevel } from '@placeos/common'; + +export interface MapListOptions { + building?: string; + search?: string; +} + +/** One level with a map, as the Map Management list shows it. */ +export interface MapRow { + id: string; + level: BuildingLevel; + name: string; + building: string; + building_id: string; + map_id: string; + map_short: string; + /** The map's file name, or the shortened URL when none is known. */ + map_label: string; +} + +export const DEFAULT_MAP_STUDIO_URL = '/map-studio/'; + +/** The levels that have a map, filtered by building and search text. */ +export function mapRows( + levels: readonly BuildingLevel[], + buildings: readonly Building[], + options: MapListOptions = {}, + names?: ReadonlyMap, +): MapRow[] { + const search = (options.search || '').trim().toLowerCase(); + const rows: MapRow[] = []; + for (const level of levels) { + if (!level.map_id) continue; + if (options.building && level.parent_id !== options.building) continue; + const building = buildings.find((item) => item.id === level.parent_id); + const name = level.display_name || level.name || level.id; + const building_name = building?.display_name || building?.name || ''; + if ( + search && + !`${name} ${level.id} ${building_name} ${level.map_id}` + .toLowerCase() + .includes(search) + ) { + continue; + } + rows.push({ + id: level.id, + level, + name, + building: building_name, + building_id: level.parent_id, + map_id: level.map_id, + map_short: shortenMapUrl(level.map_id), + map_label: mapFileLabel(level.map_id, names?.get(level.map_id)), + }); + } + return rows.sort( + (a, b) => + a.building.localeCompare(b.building) || + a.name.localeCompare(b.name), + ); +} + +/** The tail of a map URL that tells one map from another. */ +/** A map served by the uploads API, which needs the session's token and so cannot be an image's src. */ +export function isApiUpload(url: string): boolean { + return /\/api\/engine\/v2\/uploads\//.test(url || ''); +} + +export function shortenMapUrl(url: string, max = 40): string { + const trimmed = (url || '').trim(); + if (!trimmed) return ''; + const without_query = trimmed.split(/[?#]/)[0]; + const parts = without_query.split('/').filter((part) => !!part); + const tail = parts.length > 2 ? `…/${parts.slice(-2).join('/')}` : trimmed; + return tail.length > max ? `…${tail.slice(-max)}` : tail; +} + +/** + * Where "Open in Map Studio" goes: Studio's route for a level when one is + * given, otherwise the studio's root. + */ +export function mapStudioUrl( + base: string | null | undefined, + level_id?: string | null, +): string { + let root = + (base || DEFAULT_MAP_STUDIO_URL).trim() || DEFAULT_MAP_STUDIO_URL; + if (!root.endsWith('/')) root = `${root}/`; + return level_id ? `${root}#/level/${encodeURIComponent(level_id)}` : root; +} + +/** The file name at the end of a map URL when it names an SVG file; null for upload URLs and the like. */ +export function urlFileName(url: string): string | null { + const path = (url || '').trim().split(/[?#]/)[0]; + const last = + path + .split('/') + .filter((part) => !!part) + .pop() || ''; + if (!/\.svg$/i.test(last)) return null; + try { + return decodeURIComponent(last); + } catch { + return last; + } +} + +/** What the list shows for a map: its recorded file name, the URL's file name, or the shortened URL. */ +export function mapFileLabel(url: string, known_name?: string | null): string { + return known_name || urlFileName(url) || shortenMapUrl(url); +} + +/** + * The file name a save uses: the name recorded with the last save, else the + * current URL's file name, else one made from the level's name. + */ +export function mapFileName( + level: Pick, + current_url?: string | null, + known_name?: string | null, +): string { + if (known_name) return known_name; + const from_url = current_url ? urlFileName(current_url) : null; + if (from_url) return from_url; + const name = level.display_name || level.name || level.id || ''; + const slug = + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') || 'level-map'; + return `${slug}.svg`; +} + +export interface MapHistoryEntry { + upload_id: string; + map_id: string; + at: number; + by: string; + summary: string; + file_name?: string; +} + +export const MAP_HISTORY_LIMIT = 10; + +/** The history list with a new entry first, capped at the limit. */ +export function appendMapHistory( + existing: unknown, + entry: MapHistoryEntry, + limit = MAP_HISTORY_LIMIT, +): MapHistoryEntry[] { + const list = Array.isArray(existing) + ? existing.filter( + (item): item is MapHistoryEntry => + !!item && typeof item === 'object' && 'map_id' in item, + ) + : []; + return [entry, ...list].slice(0, limit); +} diff --git a/apps/concierge/src/app/map-manager/map-management.service.ts b/apps/concierge/src/app/map-manager/map-management.service.ts new file mode 100644 index 00000000000..cabe792b2fc --- /dev/null +++ b/apps/concierge/src/app/map-manager/map-management.service.ts @@ -0,0 +1,218 @@ +import { computed, effect, inject, Injectable, signal } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { + BuildingLevel, + currentUser, + OrganisationService, + SettingsService, + shareTokenWith, + UploadsService, +} from '@placeos/common'; +import { getMapDetails } from '@placeos/components'; +import { ProtectedIdSource } from '@placeos/map-editor'; +import { + PlaceZone, + showMetadata, + showZone, + updateMetadata, + updateZone, +} from '@placeos/ts-client'; +import { LevelModalComponent } from '../level-manager/level-modal.component'; +import { + appendMapHistory, + isApiUpload, + mapFileName, + MapHistoryEntry, + MapListOptions, + mapRows, + mapStudioUrl, +} from './map-list.utilities'; +import { conciergeProtectedIdSources } from './protected-ids'; + +export const MAP_HISTORY_KEY = 'map-history'; + +export interface SavedLevelMap { + upload_id: string; + map_id: string; + zone: PlaceZone; +} + +@Injectable({ + providedIn: 'root', +}) +export class MapManagementService { + private _org = inject(OrganisationService); + private _dialog = inject(MatDialog); + private _settings = inject(SettingsService); + private _uploads = inject(UploadsService); + + private _options = signal({}); + + public readonly options = this._options.asReadonly(); + public readonly buildings = this._org.building_list; + + /** File names of upload-backed maps, by map URL, read from each level's map history. */ + private readonly _map_names = signal>( + new Map(), + ); + private readonly _name_lookups = new Map>(); + + /** Levels with a map, filtered by the current options. */ + public readonly rows = computed(() => + mapRows( + this._org.level_list(), + this._org.building_list(), + this._options(), + this._map_names(), + ), + ); + + constructor() { + effect(() => { + for (const row of this.rows()) { + if (isApiUpload(row.map_id)) this._resolveMapName(row.level); + } + }); + } + + /** The file name the map was last saved under, when known. */ + public mapName(map_id: string): string | undefined { + return this._map_names().get(map_id); + } + + /** Looks up the file name a level's map was saved under; one lookup per map URL. */ + private _resolveMapName(level: BuildingLevel): Promise { + const url = level.map_id; + if (!url) return Promise.resolve(); + let lookup = this._name_lookups.get(url); + if (!lookup) { + lookup = this._lookUpMapName(level.id, url); + this._name_lookups.set(url, lookup); + } + return lookup; + } + + private async _lookUpMapName(level_id: string, url: string) { + const metadata = await showMetadata(level_id, MAP_HISTORY_KEY).catch( + () => null, + ); + const entries = Array.isArray(metadata?.details) + ? (metadata.details as MapHistoryEntry[]) + : []; + const match = entries.find( + (entry) => entry.map_id === url && !!entry.file_name, + ); + if (match?.file_name) { + const name = match.file_name; + this._map_names.update((map) => new Map(map).set(url, name)); + } + } + + public setFilters(options: Partial) { + this._options.update((current) => ({ ...current, ...options })); + } + + public setSearchString(search: string) { + this._options.update((current) => ({ ...current, search })); + } + + public level(id: string): BuildingLevel | undefined { + return this._org.level_list().find((level) => level.id === id); + } + + public building(level: BuildingLevel | undefined) { + return this._org + .building_list() + .find((building) => building.id === level?.parent_id); + } + + public editLevel(level: BuildingLevel) { + const ref = this._dialog.open(LevelModalComponent, { data: level }); + ref.afterClosed().subscribe((data) => { + if (data) this._org.addZone(data); + }); + } + + public studioUrl(level_id?: string | null): string { + return mapStudioUrl(this._settings.get('app.map_studio_url'), level_id); + } + + /** Opens Map Studio in a new tab, signed in with this app's PlaceOS token, on the level when one is given. */ + public openStudio(level_id?: string | null) { + const url = new URL(this.studioUrl(level_id), location.href); + const studio = window.open(url.href, '_blank'); + if (studio) shareTokenWith(studio, url.origin); + } + + /** The level's map as text, the way the viewer fetches it. */ + public async loadMapText(level: BuildingLevel): Promise { + const details = await getMapDetails(level.map_id); + return details.raw_data; + } + + /** Store the picture of the map an AI request carries; answers with the upload id. */ + public uploadMapSnapshot(blob: Blob, name: string): Promise { + const file = new File([blob], name, { type: blob.type || 'image/png' }); + return this._uploads.uploadFileToCompletion(file); + } + + /** The map URL the server holds now, to spot a change made elsewhere. */ + public async currentMapId(level_id: string): Promise { + const zone = await showZone(level_id); + return zone.map_id || ''; + } + + public protectedIdSources( + level: BuildingLevel, + ): Record { + return conciergeProtectedIdSources(level, this._org.organisation.id); + } + + /** + * Upload the map, point the level at it and record the version. The + * history write never fails the save. + */ + public async saveLevelMap( + level: BuildingLevel, + markup: string, + summary: string, + ): Promise { + if (isApiUpload(level.map_id)) await this._resolveMapName(level); + const file_name = mapFileName( + level, + level.map_id, + this._map_names().get(level.map_id), + ); + const file = new File([markup], file_name, { + type: 'image/svg+xml', + }); + const upload_id = await this._uploads.uploadFileToCompletion(file); + const map_id = `/api/engine/v2/uploads/${encodeURIComponent(upload_id)}/url`; + const response = await updateZone(level.id, { id: level.id, map_id }); + const zone = new PlaceZone({ ...level, ...response, map_id }); + this._org.addZone(zone); + this._map_names.update((map) => new Map(map).set(map_id, file_name)); + await this._recordHistory(level.id, { + upload_id, + map_id, + at: Date.now(), + by: currentUser()?.email || '', + summary, + file_name, + }).catch((error) => + console.warn('Map history was not recorded for', level.id, error), + ); + return { upload_id, map_id, zone }; + } + + private async _recordHistory(level_id: string, entry: MapHistoryEntry) { + const existing = await showMetadata(level_id, MAP_HISTORY_KEY).catch( + () => null, + ); + await updateMetadata(level_id, { + name: MAP_HISTORY_KEY, + details: appendMapHistory(existing?.details, entry), + description: 'Map versions saved from Concierge', + }); + } +} diff --git a/apps/concierge/src/app/map-manager/map-manager.component.ts b/apps/concierge/src/app/map-manager/map-manager.component.ts new file mode 100644 index 00000000000..8032afbfea7 --- /dev/null +++ b/apps/concierge/src/app/map-manager/map-manager.component.ts @@ -0,0 +1,331 @@ +import { Clipboard } from '@angular/cdk/clipboard'; +import { + Component, + computed, + effect, + inject, + OnDestroy, + signal, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatRippleModule } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { Router } from '@angular/router'; +import { i18n, notifySuccess } from '@placeos/common'; +import { + getMapDetails, + IconComponent, + SimpleTableComponent, + TranslatePipe, +} from '@placeos/components'; +import { ApplicationSidebarComponent } from '../ui/app-sidebar.component'; +import { SearchbarComponent } from '../ui/searchbar.component'; +import { isApiUpload, MapRow } from './map-list.utilities'; +import { MapManagementService } from './map-management.service'; + +@Component({ + selector: '[app-map-manager]', + template: ` +
+ +
+
+

+ {{ 'APP.CONCIERGE.MAPS_HEADER' | translate }} +

+
+ + +
+
+ + + + {{ + 'APP.CONCIERGE.MAPS_ALL_BUILDINGS' + | translate + }} + + @for (building of buildings(); track building.id) { + + {{ building.display_name || building.name }} + + } + + +
+
+ {{ + 'APP.CONCIERGE.MAPS_COUNT' + | translate: { count: rows().length } + }} +
+
+
+
+ +
+
+
+
+
+ +
+ @if (thumbnailSrc(row); as src) { + + } @else { + map + } +
+
+ + + + +
+ + {{ row.map_label }} + + +
+
+ +
+ + + + + + +
+
+ `, + styles: [ + ` + :host { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + background-color: var(--base-100); + } + + mat-form-field { + height: 3.25rem; + } + `, + ], + imports: [ + ApplicationSidebarComponent, + SearchbarComponent, + SimpleTableComponent, + IconComponent, + TranslatePipe, + FormsModule, + MatFormFieldModule, + MatSelectModule, + MatMenuModule, + MatRippleModule, + MatTooltipModule, + ], +}) +export class MapManagerComponent implements OnDestroy { + private readonly _manager = inject(MapManagementService); + private readonly _router = inject(Router); + private readonly _clipboard = inject(Clipboard); + /** Object URLs of the maps fetched through the API, by map URL; '' when the fetch failed. */ + private readonly _thumbnails = signal>( + new Map(), + ); + private readonly _loading = new Set(); + + constructor() { + effect(() => { + for (const row of this.rows()) this._loadThumbnail(row.map_id); + }); + } + + public ngOnDestroy() { + for (const url of this._thumbnails().values()) { + if (url) URL.revokeObjectURL(url); + } + } + + /** The image to show for a row: the map fetched with the session's token, or nothing until it arrives or when it failed. */ + public thumbnailSrc(row: MapRow): string | null { + if (this.broken().has(row.id)) return null; + const loaded = this._thumbnails().get(row.map_id); + if (loaded !== undefined) return loaded || null; + return isApiUpload(row.map_id) ? null : row.map_id; + } + + private async _loadThumbnail(url: string) { + if (!url || this._loading.has(url)) return; + this._loading.add(url); + try { + const details = await getMapDetails(url); + const object_url = URL.createObjectURL( + new Blob([details.raw_data], { type: 'image/svg+xml' }), + ); + this._thumbnails.update((map) => new Map(map).set(url, object_url)); + } catch { + this._thumbnails.update((map) => new Map(map).set(url, '')); + } + } + + public readonly rows = this._manager.rows; + public readonly options = this._manager.options; + public readonly buildings = this._manager.buildings; + public readonly broken = signal>(new Set()); + + public readonly building_count = computed(() => this.buildings().length); + + public setSearch(search: string) { + this._manager.setSearchString(search); + } + + public setBuilding(building: string) { + this._manager.setFilters({ building: building || undefined }); + } + + public editMap(row: MapRow) { + this._router.navigate(['/map-management', row.id]); + } + + public editLevel(row: MapRow) { + this._manager.editLevel(row.level); + } + + public openStudio() { + this._manager.openStudio(); + } + + public markBroken(id: string) { + this.broken.update((set) => new Set([...set, id])); + } + + public copyToClipboard(value: string) { + if (this._clipboard.copy(value)) { + notifySuccess(i18n('APP.CONCIERGE.MAPS_COPIED')); + } + } +} diff --git a/apps/concierge/src/app/map-manager/map-manager.routes.ts b/apps/concierge/src/app/map-manager/map-manager.routes.ts new file mode 100644 index 00000000000..65e48925c86 --- /dev/null +++ b/apps/concierge/src/app/map-manager/map-manager.routes.ts @@ -0,0 +1,16 @@ +import { Routes } from '@angular/router'; +import { unsavedChangesGuard } from '../ui/unsaved-changes.guard'; +import { MapManagerComponent } from './map-manager.component'; + +export const ROUTES: Routes = [ + { path: '', component: MapManagerComponent, title: 'Map Management' }, + { + path: ':level_id', + title: 'Map Management', + loadComponent: () => + import('./map-editor-page.component').then( + (m) => m.MapEditorPageComponent, + ), + canDeactivate: [unsavedChangesGuard], + }, +]; diff --git a/apps/concierge/src/app/map-manager/protected-ids.ts b/apps/concierge/src/app/map-manager/protected-ids.ts new file mode 100644 index 00000000000..23d303f6aea --- /dev/null +++ b/apps/concierge/src/app/map-manager/protected-ids.ts @@ -0,0 +1,116 @@ +import { + queryLockerAssetsForZones, + queryLockerBankAssetsForZones, + queryParkingSpacesForZones, +} from '@placeos/assets'; +import { BuildingLevel } from '@placeos/common'; +import { requestSpacesForZone } from '@placeos/events'; +import { ProtectedId, ProtectedIdSource } from '@placeos/map-editor'; +import { PlaceAsset, showMetadata } from '@placeos/ts-client'; +import { firstValueFrom } from 'rxjs'; + +interface DeskRecord { + id?: string; + map_id?: string; + name?: string; +} + +interface PointRecord { + id?: string; + name?: string; + location?: string | [number, number]; +} + +function assetMapId(asset: PlaceAsset): string { + const data = (asset.other_data || {}) as { map_id?: string }; + return asset.map_id || data.map_id || ''; +} + +/** + * Every Concierge record that binds to a map element id on the level: desks, + * rooms, parking bays, locker banks and lockers, and points of interest. Map + * regions and asset locations have no per-level source in Concierge. + */ +export function conciergeProtectedIdSources( + level: BuildingLevel, + organisation_id: string, +): Record { + return { + desks: async () => { + const metadata = await showMetadata(level.id, 'desks'); + const list = Array.isArray(metadata.details) + ? (metadata.details as DeskRecord[]) + : []; + return list + .map( + (desk): ProtectedId => ({ + id: desk.map_id || desk.id || '', + kind: 'desk', + label: desk.name || desk.id || '', + }), + ) + .filter((entry) => !!entry.id); + }, + rooms: async () => { + const spaces = await firstValueFrom(requestSpacesForZone(level.id)); + return spaces + .filter((space) => !!space.map_id) + .map( + (space): ProtectedId => ({ + id: space.map_id, + kind: 'room', + label: space.display_name || space.name || '', + }), + ); + }, + parking: async () => { + const spaces = await queryParkingSpacesForZones([level.id]); + return spaces + .map( + (space): ProtectedId => ({ + id: assetMapId(space), + kind: 'parking', + label: space.identifier || space.name || '', + }), + ) + .filter((entry) => !!entry.id); + }, + lockers: async () => { + const [banks, lockers] = await Promise.all([ + queryLockerBankAssetsForZones([level.id]), + queryLockerAssetsForZones([level.id]), + ]); + return [...banks, ...lockers] + .map( + (asset): ProtectedId => ({ + id: assetMapId(asset), + kind: 'locker', + label: asset.identifier || asset.name || '', + }), + ) + .filter((entry) => !!entry.id); + }, + points_of_interest: async () => { + const metadata = await showMetadata( + organisation_id, + 'points-of-interest', + ); + const mapping = (metadata.details || {}) as Record< + string, + PointRecord[] + >; + const list = Array.isArray(mapping[level.id]) + ? mapping[level.id] + : []; + return list + .filter((point) => typeof point.location === 'string') + .map( + (point): ProtectedId => ({ + id: point.location as string, + kind: 'poi', + label: point.name || point.id || '', + }), + ); + }, + }; +} diff --git a/apps/concierge/src/app/ui/app-sidebar.component.ts b/apps/concierge/src/app/ui/app-sidebar.component.ts index 4538faa5d6d..4061e55e6b5 100644 --- a/apps/concierge/src/app/ui/app-sidebar.component.ts +++ b/apps/concierge/src/app/ui/app-sidebar.component.ts @@ -134,6 +134,12 @@ const SIDEBAR_GROUPS: readonly SidebarGroup[] = [ route: ['/zone-management'], icon: 'business', }, + { + id: 'maps', + name: 'APP.CONCIERGE.MENU_MANAGE_MAPS', + route: ['/map-management'], + icon: 'map', + }, { id: 'spaces', name: 'APP.CONCIERGE.MENU_MANAGE_ROOMS', diff --git a/apps/concierge/src/environments/settings.schema.json b/apps/concierge/src/environments/settings.schema.json index fa5bf041179..941d3a4ad37 100644 --- a/apps/concierge/src/environments/settings.schema.json +++ b/apps/concierge/src/environments/settings.schema.json @@ -54,7 +54,7 @@ }, "features": { "type": "array", - "description": "List of features enabled in the app. Each value shows the sidebar links with the same ID. Values: `spaces`, `desks`, `parking`, `parking-bookings`, `parking-manage`, `lockers`, `assets`, `catering`, `visitors`, `visitor-rules`, `zones`, `points`, `emergency-contacts`, `signage`, `points-of-interest`, `url-management`, `email-templates`, `deals-n-offers`, `internal-users`, `events`, `surveys`, `attendance-report`, `booking-report`, `desk-report`, `parking-report`, `lockers-report`, `catering-report`, `contact-tracing-report`, `assets-report`, `visitors-report`, `user-report`", + "description": "List of features enabled in the app. Each value shows the sidebar links with the same ID. Values: `spaces`, `desks`, `parking`, `parking-bookings`, `parking-manage`, `lockers`, `assets`, `catering`, `visitors`, `visitor-rules`, `zones`, `maps`, `points`, `emergency-contacts`, `signage`, `points-of-interest`, `url-management`, `email-templates`, `deals-n-offers`, `internal-users`, `events`, `surveys`, `attendance-report`, `booking-report`, `desk-report`, `parking-report`, `lockers-report`, `catering-report`, `contact-tracing-report`, `assets-report`, `visitors-report`, `user-report`", "items": { "type": "string" } }, "feature_groups": { @@ -65,6 +65,10 @@ "items": { "type": "string" } } }, + "map_studio_url": { + "type": "string", + "description": "Where Map Management's `Open in Map Studio` link goes. Defaults to `/map-studio/`" + }, "has_uploads": { "type": "boolean", "description": "Whether file upload support (S3/cloud storage) is available in the app" diff --git a/apps/concierge/src/environments/settings.ts b/apps/concierge/src/environments/settings.ts index e970df55b05..63137c9bf78 100644 --- a/apps/concierge/src/environments/settings.ts +++ b/apps/concierge/src/environments/settings.ts @@ -16,6 +16,7 @@ const app = { features: [ 'spaces', 'zones', + 'maps', 'desks', 'parking', 'visitors', diff --git a/apps/concierge/src/tests/level-manager/level-list.component.spec.ts b/apps/concierge/src/tests/level-manager/level-list.component.spec.ts index caa1f2ee62a..af6b802780d 100644 --- a/apps/concierge/src/tests/level-manager/level-list.component.spec.ts +++ b/apps/concierge/src/tests/level-manager/level-list.component.spec.ts @@ -1,6 +1,7 @@ import { Clipboard } from '@angular/cdk/clipboard'; import { signal } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; +import { Router } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { MockProvider } from 'ng-mocks'; @@ -14,6 +15,7 @@ describe('LevelListComponent', () => { let spectator: Spectator; let manager: any; let copy: any; + let navigate: any; let notify_open: ReturnType; const createComponent = createComponentFactory({ @@ -24,6 +26,7 @@ describe('LevelListComponent', () => { MockProvider(LevelManagementService, {}), MockProvider(Clipboard, {}), MockProvider(MatDialog, { open: vi.fn() }), + MockProvider(Router, { navigate: vi.fn() }), ], }); @@ -36,15 +39,16 @@ describe('LevelListComponent', () => { manager = { filtered_levels: signal([{ id: 'lvl-1' }]), editLevel: vi.fn(), - editLevelMap: vi.fn(), removeLevel: vi.fn(), }; copy = vi.fn(() => true); + navigate = vi.fn(async () => true); spectator = createComponent({ providers: [ { provide: LevelManagementService, useValue: manager }, { provide: Clipboard, useValue: { copy } }, { provide: MatDialog, useValue: { open: vi.fn() } }, + { provide: Router, useValue: { navigate } }, ], }); }); @@ -58,13 +62,16 @@ describe('LevelListComponent', () => { it('should delegate level actions to the management service', () => { const row = { id: 'lvl-1' }; spectator.component.editLevel(row); - spectator.component.editLevelMap(row); spectator.component.removeLevel(row); expect(manager.editLevel).toHaveBeenCalledWith(row); - expect(manager.editLevelMap).toHaveBeenCalledWith(row); expect(manager.removeLevel).toHaveBeenCalledWith(row); }); + it('should open the map in Map Management', () => { + spectator.component.editLevelMap({ id: 'lvl-1' }); + expect(navigate).toHaveBeenCalledWith(['/map-management', 'lvl-1']); + }); + it('should notify only when the id copy succeeds', () => { spectator.component.copyToClipboard('lvl-1'); expect(notify_open).toHaveBeenCalledWith( diff --git a/apps/concierge/src/tests/level-manager/level-management.service.spec.ts b/apps/concierge/src/tests/level-manager/level-management.service.spec.ts index 84f3d20cfea..1d86fc735f1 100644 --- a/apps/concierge/src/tests/level-manager/level-management.service.spec.ts +++ b/apps/concierge/src/tests/level-manager/level-management.service.spec.ts @@ -78,13 +78,6 @@ describe('LevelManagementService', () => { expect(org.addZone).toHaveBeenCalledWith({ id: 'new-level' }); }); - it('should add the returned zone after editing a level map', async () => { - dialog_open.mockReturnValue({ afterClosed: () => of({ id: 'level-map' }) }); - await spectator.service.editLevelMap({ id: 'level-1' } as any); - expect(dialog_open).toHaveBeenCalled(); - expect(org.addZone).toHaveBeenCalledWith({ id: 'level-map' }); - }); - it('should not add a zone when the level dialog is dismissed', () => { dialog_open.mockReturnValue({ afterClosed: () => of(null) }); spectator.service.editLevel(); diff --git a/apps/concierge/src/tests/level-manager/level-map-editor-modal.component.spec.ts b/apps/concierge/src/tests/level-manager/level-map-editor-modal.component.spec.ts deleted file mode 100644 index c71ce8917b3..00000000000 --- a/apps/concierge/src/tests/level-manager/level-map-editor-modal.component.spec.ts +++ /dev/null @@ -1,349 +0,0 @@ -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { UploadsService } from '@placeos/common'; -import { MockProvider } from 'ng-mocks'; - -import { LevelMapEditorModalComponent } from '../../app/level-manager/level-map-editor-modal.component'; - -import * as ts_client from '@placeos/ts-client'; - -vi.mock('@placeos/ts-client', { spy: true }); - -describe('LevelMapEditorModalComponent', () => { - let spectator: Spectator; - - const SVG_MAP = ` - - - - Room A - - - - - - - - - - `; - - const level = { - id: 'level-1', - name: 'LEVEL TEST Level 1', - display_name: 'Level 1', - parent_id: 'building-1', - map_id: '', - tags: ['level'], - }; - - const createComponent = createComponentFactory({ - component: LevelMapEditorModalComponent, - shallow: true, - providers: [ - MockProvider(MAT_DIALOG_DATA, level), - MockProvider(MatDialogRef, { close: vi.fn() }), - MockProvider(UploadsService, { - uploadFileToCompletion: vi.fn(), - }), - ], - imports: [NoopAnimationsModule], - }); - - beforeEach(() => { - Object.defineProperty(URL, 'createObjectURL', { - value: vi.fn(() => 'blob:preview'), - writable: true, - }); - Object.defineProperty(URL, 'revokeObjectURL', { - value: vi.fn(), - writable: true, - }); - vi.spyOn(console, 'debug').mockImplementation(() => undefined); - vi.spyOn(console, 'warn').mockImplementation(() => undefined); - (ts_client.updateZone as any).mockResolvedValue({ - id: 'level-1', - tags: ['level'], - }); - spectator = createComponent(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('loads editable SVG elements', () => { - loadSvg(); - - expect(spectator.component.elements()).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - editor_index: 0, - id: 'room-1', - fill: '#ffffff', - stroke: '#111111', - tag: 'rect', - }), - expect.objectContaining({ - id: 'label-1', - text: 'Room A', - tag: 'text', - }), - expect.objectContaining({ - id: 'styled-room', - fill: '#123456', - stroke: '#654321', - tag: 'path', - }), - ]), - ); - expect(spectator.component.elements().map((_) => _.id)).not.toEqual( - expect.arrayContaining([ - 'hidden-fill', - 'hidden-display', - 'hidden-css', - 'template-room', - ]), - ); - expect(spectator.component.preview_url()).toBe('blob:preview'); - expect(spectator.component.dirty()).toBe(false); - }); - - it('adds selected element outline only to the overlay SVG', () => { - loadSvg(); - - const clean_markup = (spectator.component as any)._serialiseSvg(); - const overlay_markup = ( - spectator.component as any - )._serialiseSelectionOverlay(); - - expect(clean_markup).toContain('id="room-1"'); - expect(clean_markup).not.toContain('data-map-editor-highlight'); - expect(overlay_markup).toContain('data-map-editor-highlight="true"'); - expect(overlay_markup).toContain('stroke: #00A3FF !important'); - expect(spectator.component.overlay_url()).toBe('blob:preview'); - }); - - it('outlines selected and hovered sidebar elements together', () => { - loadSvg(); - const styled = elementWithId('styled-room'); - - spectator.component.hoverElement(styled.editor_index); - const hover_markup = ( - spectator.component as any - )._serialiseSelectionOverlay(); - - expect(spectator.component.selected_index()).toBe(0); - expect(spectator.component.outlined_indices()).toEqual([ - 0, - styled.editor_index, - ]); - expect(hover_markup).toContain('x="0"'); - expect(hover_markup).toContain('class="styled-room"'); - expect(hover_markup).toContain('stroke: #FFB020 !important'); - expect(hover_markup).toContain('stroke: #00A3FF !important'); - - spectator.component.clearHoveredElement(styled.editor_index); - - expect(spectator.component.outlined_indices()).toEqual([0]); - expect( - (spectator.component as any)._serialiseSelectionOverlay(), - ).toContain('stroke: #00A3FF !important'); - }); - - it('updates selected element id, colours and text', () => { - loadSvg(); - - spectator.component.selectElement(0); - spectator.component.selected_id.set('room-2'); - spectator.component.selected_fill.set('#ff0000'); - spectator.component.selected_stroke.set('#00ff00'); - spectator.component.applySelectedChanges(); - - const label = elementWithId('label-1'); - spectator.component.selectElement(label.editor_index); - spectator.component.selected_text.set('Room B'); - spectator.component.applySelectedChanges(); - - expect(spectator.component.svg_markup()).toContain('id="room-2"'); - expect(spectator.component.svg_markup()).toContain('fill="#ff0000"'); - expect(spectator.component.svg_markup()).toContain('stroke="#00ff00"'); - expect(spectator.component.svg_markup()).toContain('Room B'); - expect(spectator.component.dirty()).toBe(true); - }); - - it('shows CSS class colours in the selected element form values', () => { - loadSvg(); - const styled = elementWithId('styled-room'); - - spectator.component.selectElement(styled.editor_index); - - expect(spectator.component.selected_fill()).toBe('#123456'); - expect(spectator.component.selected_stroke()).toBe('#654321'); - }); - - it('filters the sidebar list to elements with IDs', () => { - loadSvg(); - - expect(spectator.component.elements()).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: '' }), - expect.objectContaining({ id: 'room-1' }), - ]), - ); - - spectator.component.show_only_ids.set(true); - - expect(spectator.component.filtered_elements().length).toBeLessThan( - spectator.component.elements().length, - ); - expect( - spectator.component.filtered_elements().every((_) => !!_.id), - ).toBe(true); - }); - - it('uses picked colours to update the selected element', () => { - loadSvg(); - - spectator.component.selectElement(0); - spectator.component.updateSelectedFill('#abcdef'); - spectator.component.updateSelectedStroke('#fedcba'); - - expect(spectator.component.colourPickerValue('none')).toBe('#000000'); - expect(spectator.component.colourPickerValue('#abcdef')).toBe( - '#abcdef', - ); - expect(spectator.component.svg_markup()).toContain('fill="#abcdef"'); - expect(spectator.component.svg_markup()).toContain('stroke="#fedcba"'); - }); - - it('marks elements as edited after changes', () => { - loadSvg(); - - expect(spectator.component.isElementEdited(0)).toBe(false); - spectator.component.selectElement(0); - spectator.component.selected_fill.set('#ff0000'); - spectator.component.applySelectedChanges(); - - expect(spectator.component.isElementEdited(0)).toBe(true); - }); - - it('resets an edited element back to its original SVG state', () => { - loadSvg(); - - spectator.component.selectElement(0); - spectator.component.selected_id.set('room-2'); - spectator.component.selected_fill.set('#ff0000'); - spectator.component.selected_stroke.set('#00ff00'); - spectator.component.applySelectedChanges(); - - spectator.component.resetElement(0); - - expect(spectator.component.isElementEdited(0)).toBe(false); - expect(spectator.component.selected_id()).toBe('room-1'); - expect(spectator.component.selected_fill()).toBe('#ffffff'); - expect(spectator.component.selected_stroke()).toBe('#111111'); - expect(spectator.component.svg_markup()).toContain('id="room-1"'); - expect(spectator.component.svg_markup()).toContain('fill="#ffffff"'); - expect(spectator.component.svg_markup()).not.toContain('id="room-2"'); - expect(spectator.component.dirty()).toBe(false); - }); - - it('keeps the SVG dirty when resetting one of multiple edited elements', () => { - loadSvg(); - const label = elementWithId('label-1'); - - spectator.component.selectElement(0); - spectator.component.selected_fill.set('#ff0000'); - spectator.component.applySelectedChanges(); - spectator.component.selectElement(label.editor_index); - spectator.component.selected_text.set('Room B'); - spectator.component.applySelectedChanges(); - - spectator.component.resetElement(0); - - expect(spectator.component.isElementEdited(0)).toBe(false); - expect(spectator.component.isElementEdited(label.editor_index)).toBe( - true, - ); - expect(spectator.component.svg_markup()).toContain('Room B'); - expect(spectator.component.dirty()).toBe(true); - }); - - it('does not save duplicate SVG element IDs', async () => { - loadSvg(); - spectator.component.selectElement( - elementWithId('label-1').editor_index, - ); - spectator.component.selected_id.set('room-1'); - spectator.component.applySelectedChanges(); - - await spectator.component.save(); - - const uploads = spectator.inject(UploadsService); - expect(uploads.uploadFileToCompletion).not.toHaveBeenCalled(); - expect(ts_client.updateZone).not.toHaveBeenCalled(); - }); - - it('uploads edited SVG and updates the level map URL', async () => { - loadSvg(); - spectator.component.selectElement(0); - spectator.component.selected_fill.set('#ff0000'); - spectator.component.applySelectedChanges(); - - const uploads = spectator.inject(UploadsService); - uploads.uploadFileToCompletion.mockResolvedValue( - 'upload-1', - ); - - await spectator.component.save(); - - expect(uploads.uploadFileToCompletion).toHaveBeenCalledWith( - expect.any(File), - ); - const file = uploads.uploadFileToCompletion.mock - .calls[0][0] as File; - expect(file.name).toBe('level-1.svg'); - expect(file.type).toBe('image/svg+xml'); - await expect(readFile(file)).resolves.not.toContain( - 'data-map-editor-highlight', - ); - expect(ts_client.updateZone).toHaveBeenCalledWith( - 'level-1', - expect.objectContaining({ - map_id: '/api/engine/v2/uploads/upload-1/url', - tags: ['level'], - }), - ); - expect(spectator.inject(MatDialogRef).close).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'level-1', - map_id: '/api/engine/v2/uploads/upload-1/url', - tags: ['level'], - }), - ); - }); - - function loadSvg() { - (spectator.component as any)._loadSvg(SVG_MAP); - } - - function elementWithId(id: string) { - const element = spectator.component.elements().find((_) => _.id === id); - if (!element) throw new Error(`Expected SVG element "${id}"`); - return element; - } -}); - -function readFile(file: File) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(`${reader.result || ''}`); - reader.onerror = () => reject(reader.error); - reader.readAsText(file); - }); -} diff --git a/apps/concierge/src/tests/map-manager/map-editor-page.component.spec.ts b/apps/concierge/src/tests/map-manager/map-editor-page.component.spec.ts new file mode 100644 index 00000000000..54349e01cc3 --- /dev/null +++ b/apps/concierge/src/tests/map-manager/map-editor-page.component.spec.ts @@ -0,0 +1,197 @@ +import { MatDialog } from '@angular/material/dialog'; +import { + ActivatedRoute, + convertToParamMap, + provideRouter, +} from '@angular/router'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { BuildingLevel, OrganisationService } from '@placeos/common'; +import { MapEditorAiPanelComponent } from '@placeos/map-editor'; + +import { + mapEditorAiStrings, + MapEditorPageComponent, +} from '../../app/map-manager/map-editor-page.component'; +import { MapManagementService } from '../../app/map-manager/map-management.service'; + +import * as ts_client from '@placeos/ts-client'; + +vi.mock('@placeos/ts-client', { spy: true }); + +const MAP = + '' + + '' + + ''; + +const CAPABILITIES = { + enabled: true, + providers: [ + { + id: 'provider-1', + name: 'Signage Image Gen', + provider: 'OPENAI', + text_model: 'gpt-5.4-mini', + }, + ], + default_provider_id: 'provider-1', + quota: { user_remaining_today: 39, domain_remaining_month: 980 }, + limits: { + max_context_bytes: 200000, + max_ops: 80, + max_instruction_chars: 2000, + }, +}; + +describe('MapEditorPageComponent', () => { + let spectator: Spectator; + let manager: Record>; + const level = new BuildingLevel({ + id: 'l1', + parent_id: 'b1', + name: 'Level 1', + display_name: 'L1', + map_id: '/maps/l1.svg', + }); + + const createComponent = createComponentFactory({ + component: MapEditorPageComponent, + detectChanges: false, + providers: [ + provideRouter([]), + { + provide: ActivatedRoute, + useValue: { + snapshot: { + paramMap: convertToParamMap({ level_id: 'l1' }), + }, + }, + }, + { provide: MatDialog, useValue: { open: vi.fn() } }, + { + provide: OrganisationService, + useValue: { + waitUntilInitialised: vi.fn(async () => undefined), + }, + }, + { provide: MapManagementService, useValue: {} }, + ], + }); + + beforeEach(() => { + vi.clearAllMocks(); + manager = { + level: vi.fn(() => level), + building: vi.fn(() => undefined), + loadMapText: vi.fn(async () => MAP), + protectedIdSources: vi.fn(() => ({})), + uploadMapSnapshot: vi.fn(async () => 'upload-1'), + openStudio: vi.fn(), + currentMapId: vi.fn(async () => level.map_id), + saveLevelMap: vi.fn(), + }; + }); + + afterEach(() => { + spectator?.fixture.destroy(); + }); + + async function settle(rounds = 8) { + for (let i = 0; i < rounds; i++) { + spectator.detectChanges(); + await spectator.fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + spectator.detectChanges(); + } + + async function mount(capabilities: () => Promise) { + vi.mocked(ts_client.get).mockImplementation((url: string) => + url.endsWith('/maps/ai/capabilities') + ? (capabilities() as Promise) + : Promise.reject({ status: 404, message: 'Not found' }), + ); + spectator = createComponent({ + providers: [{ provide: MapManagementService, useValue: manager }], + }); + spectator.detectChanges(); + await settle(); + } + + it('mounts the AI panel in the editor on the level session', async () => { + await mount(async () => CAPABILITIES); + const session = spectator.component.session(); + expect(session).toBeTruthy(); + const panel = spectator.query(MapEditorAiPanelComponent); + expect(panel).toBeTruthy(); + expect(panel?.session()).toBe(session); + expect(panel?.zone_id()).toBe('l1'); + expect(panel?.upload()).toBe(spectator.component.upload_port); + expect(panel?.strings()).toBe(spectator.component.ai_strings); + expect( + spectator.query('map-editor .panel map-editor-ai-panel'), + ).toBeTruthy(); + expect(spectator.query('map-editor-ai-panel textarea')).toBeTruthy(); + expect(spectator.component.ai_hidden()).toBe(false); + }); + + it('drops the panel when the server has no map AI', async () => { + await mount(() => + Promise.reject({ status: 404, message: 'Not found' }), + ); + expect(spectator.component.session()).toBeTruthy(); + expect(spectator.component.ai_hidden()).toBe(true); + expect(spectator.query('map-editor-ai-panel')).toBeNull(); + expect(spectator.query('map-editor .panel')?.children.length).toBe(0); + }); + + it('keeps the panel, with its note, when the feature is off', async () => { + await mount(async () => ({ + ...CAPABILITIES, + enabled: false, + reason: 'no AI provider is configured for this domain', + })); + expect(spectator.component.ai_hidden()).toBe(false); + expect( + spectator.query('map-editor-ai-panel [unavailable]')?.textContent, + ).toContain('no AI provider is configured for this domain'); + }); + + it('stores the AI snapshot through the management service', async () => { + await mount(async () => CAPABILITIES); + const blob = new Blob(['png'], { type: 'image/png' }); + await expect( + spectator.component.upload_port(blob, 'map-snapshot.png'), + ).resolves.toBe('upload-1'); + expect(manager.uploadMapSnapshot).toHaveBeenCalledWith( + blob, + 'map-snapshot.png', + ); + }); +}); + +describe('mapEditorAiStrings', () => { + it('reads MAP_EDITOR.AI.* keys and keeps the English default elsewhere', () => { + const translations: Record = { + 'MAP_EDITOR.AI.TITLE': 'Edit with the assistant', + 'MAP_EDITOR.AI.REASONS.TOO_MUCH': 'Overdone', + 'MAP_EDITOR.AI.KINDS.SET_ATTR': 'Alter', + }; + const strings = mapEditorAiStrings((key) => translations[key] ?? key); + expect(strings.title).toBe('Edit with the assistant'); + expect(strings.reasons?.too_much).toBe('Overdone'); + expect(strings.reasons?.other).toBe('Other'); + expect(strings.kinds?.set_attr).toBe('Alter'); + expect(strings.kinds?.delete).toBe('Delete'); + expect(strings.states?.quota).toBe( + 'The AI edit allowance has been used up.', + ); + }); + + it('falls back to the default when the lookup answers with the key or nothing', () => { + const strings = mapEditorAiStrings((key) => + key === 'MAP_EDITOR.AI.SEND' ? '' : key, + ); + expect(strings.send).toBe('Send'); + expect(strings.keep).toBe('Keep'); + }); +}); diff --git a/apps/concierge/src/tests/map-manager/map-list.utilities.spec.ts b/apps/concierge/src/tests/map-manager/map-list.utilities.spec.ts new file mode 100644 index 00000000000..2e8d72fdda6 --- /dev/null +++ b/apps/concierge/src/tests/map-manager/map-list.utilities.spec.ts @@ -0,0 +1,179 @@ +import { Building, BuildingLevel } from '@placeos/common'; + +import { + appendMapHistory, + mapFileLabel, + mapFileName, + mapRows, + mapStudioUrl, + shortenMapUrl, + urlFileName, +} from '../../app/map-manager/map-list.utilities'; + +const buildings = [ + new Building({ id: 'b1', name: 'Alpha', display_name: 'Alpha Tower' }), + new Building({ id: 'b2', name: 'Beta' }), +]; + +const levels = [ + new BuildingLevel({ + id: 'l1', + parent_id: 'b1', + name: 'Level 1', + display_name: 'L1', + map_id: 'https://maps.example.com/alpha/level_1.svg', + }), + new BuildingLevel({ + id: 'l2', + parent_id: 'b1', + name: 'Level 2', + map_id: '', + }), + new BuildingLevel({ + id: 'l3', + parent_id: 'b2', + name: 'Ground', + map_id: '/api/engine/v2/uploads/upload-123/url', + }), + new BuildingLevel({ + id: 'l4', + parent_id: 'b2', + name: 'Basement', + map_id: '/maps/basement.svg', + }), +]; + +describe('mapRows', () => { + it('lists only levels with a map, sorted by building then name', () => { + const rows = mapRows(levels, buildings); + expect(rows.map((row) => row.id)).toEqual(['l1', 'l4', 'l3']); + expect(rows[0]).toMatchObject({ + name: 'L1', + building: 'Alpha Tower', + building_id: 'b1', + map_short: '…/alpha/level_1.svg', + map_label: 'level_1.svg', + }); + expect(rows[2].building).toBe('Beta'); + }); + + it('filters by building and by search text across name, id, building and url', () => { + expect( + mapRows(levels, buildings, { building: 'b2' }).map((r) => r.id), + ).toEqual(['l4', 'l3']); + expect( + mapRows(levels, buildings, { search: 'ground' }).map((r) => r.id), + ).toEqual(['l3']); + expect( + mapRows(levels, buildings, { search: 'ALPHA' }).map((r) => r.id), + ).toEqual(['l1']); + expect( + mapRows(levels, buildings, { search: 'upload-123' }).map( + (r) => r.id, + ), + ).toEqual(['l3']); + expect(mapRows(levels, buildings, { search: 'nothing' })).toEqual([]); + }); +}); + +describe('map helpers', () => { + it('shortens map urls to their tail', () => { + expect(shortenMapUrl('')).toBe(''); + expect(shortenMapUrl('/maps/basement.svg')).toBe('/maps/basement.svg'); + expect( + shortenMapUrl('https://maps.example.com/alpha/level_1.svg?x=1'), + ).toBe('…/alpha/level_1.svg'); + expect(shortenMapUrl('/api/engine/v2/uploads/upload-123/url')).toBe( + '…/upload-123/url', + ); + expect(shortenMapUrl('/a/' + 'x'.repeat(60) + '/y.svg', 20)).toBe( + '…' + ('…/' + 'x'.repeat(60) + '/y.svg').slice(-20), + ); + }); + + it('builds the map studio link from the setting', () => { + expect(mapStudioUrl(undefined)).toBe('/map-studio/'); + expect(mapStudioUrl('')).toBe('/map-studio/'); + expect(mapStudioUrl('https://studio.example.com')).toBe( + 'https://studio.example.com/', + ); + expect(mapStudioUrl('/map-studio/', 'zone 1')).toBe( + '/map-studio/#/level/zone%201', + ); + }); + + it('reads a file name from a map URL and labels upload URLs by their recorded name', () => { + expect(urlFileName('/maps/alpha/level_1.svg?v=2')).toBe('level_1.svg'); + expect(urlFileName('https://os.place.tech/x/Level%201.svg')).toBe( + 'Level 1.svg', + ); + expect(urlFileName('/api/engine/v2/uploads/upload-9/url')).toBeNull(); + expect( + mapFileLabel('/api/engine/v2/uploads/upload-9/url', 'level_1.svg'), + ).toBe('level_1.svg'); + expect(mapFileLabel('/api/engine/v2/uploads/upload-9/url')).toBe( + '…/upload-9/url', + ); + const names = new Map([ + ['/api/engine/v2/uploads/upload-9/url', 'level_1.svg'], + ]); + const saved = new BuildingLevel({ + id: 'l9', + parent_id: 'b1', + name: 'L9', + display_name: 'L9', + map_id: '/api/engine/v2/uploads/upload-9/url', + tags: ['level'], + }); + expect(mapRows([saved], buildings, {}, names)[0].map_label).toBe( + 'level_1.svg', + ); + }); + + it('keeps the file name across saves: the recorded name, else the URL file name, else the level', () => { + const level = { display_name: 'Level 1 (East)', name: '', id: 'l1' }; + expect( + mapFileName( + level, + '/api/engine/v2/uploads/upload-9/url', + 'level_1.svg', + ), + ).toBe('level_1.svg'); + expect(mapFileName(level, '/maps/alpha/level_1.svg')).toBe( + 'level_1.svg', + ); + expect(mapFileName(level, '/api/engine/v2/uploads/upload-9/url')).toBe( + 'level-1-east.svg', + ); + }); + + it('names the uploaded file after the level', () => { + expect( + mapFileName({ display_name: 'Level 1 (East)', name: '', id: 'l1' }), + ).toBe('level-1-east.svg'); + expect(mapFileName({ display_name: '', name: '', id: '' })).toBe( + 'level-map.svg', + ); + }); + + it('keeps the newest ten history entries, newest first', () => { + const entry = (n: number) => ({ + upload_id: `u${n}`, + map_id: `/m/${n}`, + at: n, + by: 'a@b.c', + summary: '', + }); + const existing = Array.from({ length: 12 }, (_, i) => entry(i)); + const list = appendMapHistory(existing, entry(99)); + expect(list.length).toBe(10); + expect(list[0].upload_id).toBe('u99'); + expect(list[9].upload_id).toBe('u8'); + expect(appendMapHistory(undefined, entry(1))).toEqual([entry(1)]); + expect(appendMapHistory({ bad: true }, entry(1))).toEqual([entry(1)]); + expect(appendMapHistory([null, 'x', entry(2)], entry(1))).toEqual([ + entry(1), + entry(2), + ]); + }); +}); diff --git a/apps/concierge/src/tests/map-manager/map-management.service.spec.ts b/apps/concierge/src/tests/map-manager/map-management.service.spec.ts new file mode 100644 index 00000000000..e98baee9bf0 --- /dev/null +++ b/apps/concierge/src/tests/map-manager/map-management.service.spec.ts @@ -0,0 +1,211 @@ +import { signal } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { + createServiceFactory, + SpectatorService, +} from '@ngneat/spectator/vitest'; +import { + BuildingLevel, + OrganisationService, + SettingsService, + UploadsService, +} from '@placeos/common'; + +import { + MAP_HISTORY_KEY, + MapManagementService, +} from '../../app/map-manager/map-management.service'; + +import * as ts_client from '@placeos/ts-client'; + +vi.mock('@placeos/ts-client', { spy: true }); + +describe('MapManagementService', () => { + let spectator: SpectatorService; + let org: any; + let settings: any; + let uploads: any; + const level = new BuildingLevel({ + id: 'l1', + parent_id: 'b1', + name: 'Level 1', + display_name: 'L1', + map_id: '/maps/old.svg', + tags: ['level'], + }); + + const createService = createServiceFactory({ + service: MapManagementService, + providers: [ + { provide: MatDialog, useValue: { open: vi.fn() } }, + { provide: OrganisationService, useValue: {} }, + { provide: SettingsService, useValue: {} }, + { provide: UploadsService, useValue: {} }, + ], + }); + + beforeEach(() => { + vi.clearAllMocks(); + org = { + level_list: signal([ + level, + new BuildingLevel({ + id: 'l2', + parent_id: 'b1', + name: 'Level 2', + }), + ]), + building_list: signal([{ id: 'b1', name: 'Alpha' }]), + organisation: { id: 'org-1' }, + addZone: vi.fn(), + }; + settings = { get: vi.fn(() => undefined) }; + uploads = { uploadFileToCompletion: vi.fn(async () => 'upload-9') }; + vi.mocked(ts_client.updateZone).mockImplementation( + async (id: string, form: any) => + ({ id, ...form, tags: ['level'] }) as any, + ); + vi.mocked(ts_client.showMetadata).mockResolvedValue({ + details: [ + { upload_id: 'u1', map_id: '/m/1', at: 1, by: '', summary: '' }, + ], + } as any); + vi.mocked(ts_client.updateMetadata).mockResolvedValue({} as any); + vi.mocked(ts_client.showZone).mockResolvedValue({ + map_id: '/maps/elsewhere.svg', + } as any); + spectator = createService({ + providers: [ + { provide: OrganisationService, useValue: org }, + { provide: SettingsService, useValue: settings }, + { provide: UploadsService, useValue: uploads }, + ], + }); + }); + + it('lists the levels with a map and applies the filters', () => { + expect(spectator.service.rows().map((row) => row.id)).toEqual(['l1']); + spectator.service.setSearchString('nothing'); + expect(spectator.service.rows()).toEqual([]); + spectator.service.setSearchString(''); + spectator.service.setFilters({ building: 'b2' }); + expect(spectator.service.rows()).toEqual([]); + spectator.service.setFilters({ building: undefined }); + expect(spectator.service.level('l1')).toBe(level); + expect(spectator.service.building(level)?.name).toBe('Alpha'); + }); + + it('uploads the map, points the level at it and records the version', async () => { + const saved = await spectator.service.saveLevelMap( + level, + '', + 'Recolour', + ); + const file: File = uploads.uploadFileToCompletion.mock.calls[0][0]; + expect(file.name).toBe('old.svg'); + expect( + spectator.service.mapName('/api/engine/v2/uploads/upload-9/url'), + ).toBe('old.svg'); + expect(file.type).toBe('image/svg+xml'); + expect(saved.upload_id).toBe('upload-9'); + expect(saved.map_id).toBe('/api/engine/v2/uploads/upload-9/url'); + expect(ts_client.updateZone).toHaveBeenCalledWith('l1', { + id: 'l1', + map_id: '/api/engine/v2/uploads/upload-9/url', + }); + expect(org.addZone).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'l1', + map_id: '/api/engine/v2/uploads/upload-9/url', + tags: ['level'], + }), + ); + expect(ts_client.showMetadata).toHaveBeenCalledWith( + 'l1', + MAP_HISTORY_KEY, + ); + const [metadata_id, metadata] = vi.mocked(ts_client.updateMetadata).mock + .calls[0] as any[]; + expect(metadata_id).toBe('l1'); + expect(metadata.name).toBe(MAP_HISTORY_KEY); + expect(metadata.details[0]).toMatchObject({ + upload_id: 'upload-9', + map_id: '/api/engine/v2/uploads/upload-9/url', + summary: 'Recolour', + file_name: 'old.svg', + }); + expect(metadata.details[1].upload_id).toBe('u1'); + }); + + it('names the saved file after the current upload even when the list has not looked it up yet', async () => { + const uploaded = new BuildingLevel({ + ...level, + id: 'l9', + map_id: '/api/engine/v2/uploads/u7/url', + }); + vi.mocked(ts_client.showMetadata).mockResolvedValue({ + details: [ + { + upload_id: 'u7', + map_id: '/api/engine/v2/uploads/u7/url', + at: 1, + by: '', + summary: '', + file_name: 'ground-floor.svg', + }, + ], + } as any); + await spectator.service.saveLevelMap(uploaded, '', ''); + const file: File = uploads.uploadFileToCompletion.mock.calls[0][0]; + expect(file.name).toBe('ground-floor.svg'); + }); + + it('still saves when the history write fails', async () => { + vi.mocked(ts_client.updateMetadata).mockRejectedValue(new Error('403')); + const warn = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + const saved = await spectator.service.saveLevelMap(level, '', ''); + expect(saved.map_id).toBe('/api/engine/v2/uploads/upload-9/url'); + expect(org.addZone).toHaveBeenCalled(); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('fails the save when the upload fails and leaves the level alone', async () => { + uploads.uploadFileToCompletion.mockRejectedValue( + new Error('no storage'), + ); + await expect( + spectator.service.saveLevelMap(level, '', ''), + ).rejects.toThrow('no storage'); + expect(ts_client.updateZone).not.toHaveBeenCalled(); + expect(org.addZone).not.toHaveBeenCalled(); + }); + + it('reads the current map url and the studio link from settings', async () => { + expect(await spectator.service.currentMapId('l1')).toBe( + '/maps/elsewhere.svg', + ); + expect(spectator.service.studioUrl()).toBe('/map-studio/'); + settings.get.mockImplementation((key: string) => + key === 'app.map_studio_url' + ? 'https://studio.example.com' + : undefined, + ); + expect(spectator.service.studioUrl()).toBe( + 'https://studio.example.com/', + ); + }); + + it('stores an AI snapshot as a file and answers with the upload id', async () => { + const blob = new Blob(['png'], { type: 'image/png' }); + await expect( + spectator.service.uploadMapSnapshot(blob, 'map-snapshot.png'), + ).resolves.toBe('upload-9'); + const file: File = uploads.uploadFileToCompletion.mock.calls[0][0]; + expect(file.name).toBe('map-snapshot.png'); + expect(file.type).toBe('image/png'); + expect(file.size).toBe(3); + }); +}); diff --git a/apps/map-studio/project.json b/apps/map-studio/project.json index b788766bd84..467dcbdbaa5 100644 --- a/apps/map-studio/project.json +++ b/apps/map-studio/project.json @@ -84,7 +84,7 @@ { "type": "anyComponentStyle", "maximumWarning": "6kb", - "maximumError": "10kb" + "maximumError": "16kb" } ], "serviceWorker": "apps/map-studio/ngsw-config.json" diff --git a/apps/map-studio/src/app/app.routes.ts b/apps/map-studio/src/app/app.routes.ts index 6b102b479e1..40fbec50882 100644 --- a/apps/map-studio/src/app/app.routes.ts +++ b/apps/map-studio/src/app/app.routes.ts @@ -1,6 +1,27 @@ -import { Routes } from '@angular/router'; +import { inject } from '@angular/core'; +import { ActivatedRouteSnapshot, Router, Routes } from '@angular/router'; +import { StoreService } from './data/store.service'; + +/** Opens the floorplan for a PlaceOS level, which is how Concierge links here. */ +async function openLevel(route: ActivatedRouteSnapshot) { + const router = inject(Router); + const store = inject(StoreService); + const level_id = route.paramMap.get('level_id') ?? ''; + try { + const floorplan = await store.floorplanForLevel(level_id); + return router.createUrlTree(['/editor', floorplan.id]); + } catch (error) { + console.warn(`Failed to open level ${level_id}`, error); + return router.createUrlTree(['/']); + } +} export const routes: Routes = [ + { + path: 'level/:level_id', + canActivate: [openLevel], + children: [], + }, { path: 'editor/:floorplan_id', loadComponent: () => diff --git a/apps/map-studio/src/app/data/placeos.service.ts b/apps/map-studio/src/app/data/placeos.service.ts index fdd9096593e..221c76fb3cf 100644 --- a/apps/map-studio/src/app/data/placeos.service.ts +++ b/apps/map-studio/src/app/data/placeos.service.ts @@ -1,6 +1,10 @@ import { Injectable, signal } from '@angular/core'; +import { requestTokenFromOpener } from '@placeos/common'; import { + authorise, authority, + PlaceAuthOptions, + setToken, setup, showMetadata, token, @@ -12,6 +16,7 @@ import type { Floorplan } from './types'; 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; function withDeadline(promise: Promise, message: string): Promise { return new Promise((resolve, reject) => { @@ -32,12 +37,15 @@ function withDeadline(promise: Promise, message: string): Promise { }); } -function fetchWithDeadline( +function fetchWithDeadline( input: RequestInfo | URL, - init: RequestInit = {}, -): Promise { + init: RequestInit, + read: (response: Response) => T | Promise, +): Promise { const controller = new AbortController(); - const request = fetch(input, { ...init, signal: controller.signal }); + const request = fetch(input, { ...init, signal: controller.signal }).then( + read, + ); return withDeadline(request, 'PlaceOS request timed out.').finally(() => controller.abort(), ); @@ -127,6 +135,7 @@ function loadSettings(): PlaceOSSettings { export class PlaceOSService { private readonly _settings = signal(loadSettings()); private readonly _mode = signal('checking'); + private _handoff_timer?: ReturnType; /** Current connection settings */ public readonly settings = this._settings.asReadonly(); @@ -138,32 +147,52 @@ export class PlaceOSService { * flow if an authority is available on the current origin. */ public async init(): Promise { - const resolved = await fetchWithDeadline('/auth/authority', { - credentials: 'same-origin', - }) - .then((resp) => resp.ok) - .catch(() => false); + const resolved = await fetchWithDeadline( + '/auth/authority', + { credentials: 'same-origin' }, + (resp) => resp.ok, + ).catch(() => false); if (!resolved) return this._mode.set('manual'); - // Redirects to login when there's no session, and comes back here + // ts-client keeps this object and reads handle_login on every authorise + const options: PlaceAuthOptions = { + auth_uri: '/auth/oauth/authorize', + token_uri: '/auth/token', + redirect_uri: new URL( + 'oauth-resp.html', + document.baseURI, + ).toString(), + scope: 'public', + handle_login: false, + }; try { - await withDeadline( - setup({ - auth_uri: '/auth/oauth/authorize', - token_uri: '/auth/token', - redirect_uri: new URL( - 'oauth-resp.html', - document.baseURI, - ).toString(), - scope: 'public', - }), - 'PlaceOS setup timed out.', - ); + await withDeadline(setup(options), 'PlaceOS setup timed out.'); + if (!token()) await this._useOpenerToken(); + options.handle_login = true; + // With handle_login on, this redirects to the login page + if (!token()) return void authorise().catch(() => undefined); this._mode.set('domain'); } catch { this._mode.set('manual'); } } + /** + * Signs in with the token of the app that opened this tab, such as + * Concierge, and asks it again shortly before that token expires. + */ + private async _useOpenerToken(): Promise { + const handed = await requestTokenFromOpener(); + if (!handed) return; + if (handed.expires_at && handed.expires_at <= Date.now()) return; + setToken(handed.token, handed.expires_at); + if (!handed.expires_at) return; + clearTimeout(this._handoff_timer); + this._handoff_timer = setTimeout( + () => void this._useOpenerToken(), + Math.max(handed.expires_at - Date.now(), HANDOFF_MIN_DELAY_MS), + ); + } + public get config(): PlaceOSConfig { const { domain, api_key } = this._settings(); if (this._mode() === 'domain') { @@ -306,6 +335,13 @@ export class PlaceOSService { } 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, + ): Promise<{ map_id: string; svg: string } | null> { const map_id = (await this.getZone(level_id)).map_id; if (!map_id) return null; @@ -329,7 +365,7 @@ export class PlaceOSService { if (!/]/i.test(svg)) { throw new Error('The level map is not an SVG'); } - return svg; + return { map_id, svg }; } // ── Systems ───────────────────────────────────────────────────────────── @@ -458,7 +494,7 @@ export class PlaceOSService { const on_domain = this._mode() === 'domain'; if (!on_domain && !api_key) throw new Error('PlaceOS not configured'); const query = new URLSearchParams(params).toString(); - const response = await fetchWithDeadline( + return fetchWithDeadline( `${on_domain ? '' : domain}/api/engine/v2${path}${query ? `?${query}` : ''}`, { ...options, @@ -470,13 +506,15 @@ export class PlaceOSService { ...(options.headers ?? {}), }, }, + async (response) => { + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new Error( + `PlaceOS ${response.status}: ${detail || response.statusText}`, + ); + } + return response.json() as Promise; + }, ); - if (!response.ok) { - const detail = await response.text().catch(() => ''); - throw new Error( - `PlaceOS ${response.status}: ${detail || response.statusText}`, - ); - } - return response.json() as Promise; } } diff --git a/apps/map-studio/src/app/data/store.service.ts b/apps/map-studio/src/app/data/store.service.ts index d4b77076e80..5175cc96859 100644 --- a/apps/map-studio/src/app/data/store.service.ts +++ b/apps/map-studio/src/app/data/store.service.ts @@ -1,4 +1,4 @@ -import { inject, Injectable } from '@angular/core'; +import { inject, Injectable, signal } from '@angular/core'; import { DbService } from './db'; import type { PlaceOSZone } from './placeos.service'; @@ -41,6 +41,14 @@ const key = { }; const now = () => new Date().toISOString(); + +function serverSaveMessage(error: unknown): string { + const detail = error instanceof Error ? error.message : String(error ?? ''); + const status = (error as { status?: number } | null)?.status; + return status === 413 || /\b413\b/.test(detail) + ? 'Saved in this browser only. The floorplan is too large to store in PlaceOS.' + : 'Saved in this browser only. PlaceOS did not accept the floorplan.'; +} const uuid = () => crypto.randomUUID(); const CSV_COLUMNS = [ @@ -105,6 +113,10 @@ function toCsvField(value: unknown): string { export class StoreService { private readonly _db = inject(DbService); private readonly _placeos = inject(PlaceOSService); + public readonly zone_sync_version = signal(0); + /** Why the last floorplan save reached this browser but not PlaceOS; empty once one does. */ + public readonly server_save_error = signal(''); + private _zone_sync: Promise = Promise.resolve(); // ── Projects ──────────────────────────────────────────────────────────── @@ -219,8 +231,36 @@ export class StoreService { return floorplan; } - /** Creates the projects and floorplans missing from the PlaceOS zone tree. */ - public async syncPlaceOSZones( + /** + * Creates the projects and floorplans missing from the PlaceOS zone tree. + * Calls run one after another, so concurrent syncs never add a floor twice. + */ + public syncPlaceOSZones( + buildings: { zone: ZoneReference; levels: ZoneReference[] }[], + ): Promise { + const run = this._zone_sync.then(() => this._syncZones(buildings)); + this._zone_sync = run.catch(() => undefined); + return run; + } + + /** The local floorplan for a PlaceOS level, adding its building's floors first when it has none. */ + public async floorplanForLevel(level_id: string): Promise { + const find = async () => + (await this._db.list('floorplan:')).find( + (floorplan) => floorplan.level_zone_id === level_id, + ); + const existing = await find(); + if (existing) return existing; + const level = await this._placeos.getZone(level_id); + const building = await this._placeos.getZone(level.parent_id); + const levels = await this._placeos.getZones('level', building.id); + await this.syncPlaceOSZones([{ zone: building, levels }]); + const added = await find(); + if (!added) throw new Error('This level was not found in PlaceOS.'); + return added; + } + + private async _syncZones( buildings: { zone: ZoneReference; levels: ZoneReference[] }[], ): Promise { const projects = await this.listProjects(); @@ -257,6 +297,7 @@ export class StoreService { existing_levels.add(level.id); } } + this.zone_sync_version.update((version) => version + 1); } public async getFloorplan(id: string): Promise { @@ -307,10 +348,18 @@ export class StoreService { const updated = { ...floorplan, ...updates, id, updated_at: now() }; await this._db.set(key.floorplan(id), updated); if (updated.level_zone_id && this._placeos.mode() === 'domain') { - const server_updated_at = await this._placeos.saveFloorplanMetadata( - updated.level_zone_id, - updated, - ); + const server_updated_at = await this._placeos + .saveFloorplanMetadata(updated.level_zone_id, updated) + .then( + (saved) => { + this.server_save_error.set(''); + return saved; + }, + (error: unknown) => { + this.server_save_error.set(serverSaveMessage(error)); + return undefined; + }, + ); if (server_updated_at) { updated.updated_at = server_updated_at; await this._db.set(key.floorplan(id), updated); @@ -366,6 +415,8 @@ export class StoreService { 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, }); } @@ -380,6 +431,8 @@ export class StoreService { 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(), @@ -422,17 +475,23 @@ export class StoreService { public async importLevelSvg( id: string, svg_content: string, + map_id?: string, ): Promise { const floorplan = await this.getFloorplan(id); const analysis = analyzeSvg(svg_content); + if (!analysis.sourceSvg) + throw new Error('The file is not a valid SVG map.'); const blob = new Blob([svg_content], { type: 'image/svg+xml' }); const updated: Floorplan = { ...floorplan, - svg_output: svg_content, + svg_source: analysis.sourceSvg, + svg_source_ids: analysis.objects.map((object) => object.svgId), + svg_output: analysis.sourceSvg || svg_content, canvas_width: analysis.width, canvas_height: analysis.height, source_image_path: URL.createObjectURL(blob), source_type: 'image/svg+xml', + source_map_id: map_id ?? floorplan.source_map_id ?? null, updated_at: now(), }; await Promise.all([ @@ -467,12 +526,17 @@ export class StoreService { outline_points, } = options; + if (!analysis.sourceSvg) + throw new Error('The file is not a valid SVG map.'); + const project = await this.createProject(project_name); const floorplan = await this.addFloorplan(project.id, floor_name, 0); const has_outline = (outline_points?.length ?? 0) >= 3; await this.updateFloorplan(floorplan.id, { - svg_output: svg_content, + svg_source: analysis.sourceSvg, + svg_source_ids: analysis.objects.map((object) => object.svgId), + svg_output: analysis.sourceSvg || svg_content, canvas_width: analysis.width, canvas_height: analysis.height, source_type: 'svg-import', @@ -480,6 +544,11 @@ export class StoreService { const chosen = new Map(mappings.map((m) => [m.svgId, m])); const objects = this._objectsFromSvg(analysis, chosen); + await this.updateFloorplan(floorplan.id, { + svg_source_ids: objects.flatMap((object) => + object.svg_id ? [object.svg_id] : [], + ), + }); if (has_outline) { const points = outline_points as { x: number; y: number }[]; @@ -520,20 +589,53 @@ export class StoreService { >, ): Partial[] { const objects: Partial[] = []; - for (const parsed of analysis.objects) { + for (const [index, parsed] of analysis.objects.entries()) { const mapping = mappings?.get(parsed.svgId); if (mappings && !mapping) continue; objects.push({ object_type: (mapping?.objectType ?? parsed.suggestedType) as MapObjectType, svg_id: parsed.svgId, - label: mapping?.label || parsed.label || parsed.svgId, + label: + mapping?.label || + parsed.label || + (parsed.tag === 'text' ? '' : parsed.svgId), 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), layer: parsed.layer, + group_id: parsed.groupId, + z_index: index, metadata: { source: 'svg-import', 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), + parentMatrix: parsed.parentMatrix, }, }); } diff --git a/apps/map-studio/src/app/data/svg-import.ts b/apps/map-studio/src/app/data/svg-import.ts index 6b529318fb9..0f20feed93d 100644 --- a/apps/map-studio/src/app/data/svg-import.ts +++ b/apps/map-studio/src/app/data/svg-import.ts @@ -1,3 +1,9 @@ +import { + invert, + multiply, + sanitiseTree, + translation, +} from '@placeos/map-editor'; import { MapObjectType, ObjectGeometry } from './types'; /** @@ -23,7 +29,21 @@ export interface ParsedObject { geometry: ObjectGeometry; /** Editor layer id — always set, so nothing is silently dropped */ layer: string; + groupId?: string | null; attributes: Record; + presentation?: { + fill: string | null; + stroke: string | null; + opacity: number; + }; + parentMatrix?: { + a: number; + b: number; + c: number; + d: number; + e: number; + f: number; + }; } export interface SvgIssue { @@ -32,6 +52,7 @@ export interface SvgIssue { } export interface SvgAnalysis { + sourceSvg?: string; width: number; height: number; viewBox: string | null; @@ -47,6 +68,10 @@ const INTERACTIVE_TAGS = [ 'path', 'ellipse', 'polyline', + 'line', + 'text', + 'use', + 'image', ]; const TYPE_PATTERNS: { pattern: RegExp; type: MapObjectType }[] = [ @@ -182,7 +207,11 @@ function extractLabel(el: Element): string | null { const value = el.getAttribute(attr); if (value) return value; } - return el.querySelector('title')?.textContent?.trim() || null; + return ( + el.querySelector('title')?.textContent?.trim() || + (el.localName === 'text' ? el.textContent?.trim() : null) || + null + ); } /** Editor layer of the nearest ancestor group carrying an id */ @@ -190,13 +219,30 @@ function findParentLayer(el: Element): string { let current = el.parentElement; while (current) { const id = current.getAttribute('id'); - if (current.tagName.toLowerCase() === 'g' && id) + if ( + current.tagName.toLowerCase() === 'g' && + id && + LAYER_NAME_MAP[id.toLowerCase()] + ) return mapLayerName(id); current = current.parentElement; } return UNGROUPED_LAYER; } +function findParentGroup(el: Element): string | null { + let current = el.parentElement; + while (current) { + const id = current.getAttribute('id'); + if (current.localName === 'g' && id) + return LAYER_NAME_MAP[id.toLowerCase()] || /^Layer[_-]/i.test(id) + ? null + : id; + current = current.parentElement; + } + return null; +} + function isGeometryValid(geometry: ObjectGeometry): boolean { switch (geometry.type) { case 'rect': @@ -215,8 +261,16 @@ function isGeometryValid(geometry: ObjectGeometry): boolean { /** Parse an SVG floor plan into layers and placeable objects */ export function analyzeSvg(svg_content: string): SvgAnalysis { const issues: SvgIssue[] = []; - const doc = new DOMParser().parseFromString(svg_content, 'image/svg+xml'); - const svg = doc.querySelector('svg'); + let doc = new DOMParser().parseFromString(svg_content, 'image/svg+xml'); + let svg = doc.querySelector('svg'); + if (svg && !svg.namespaceURI) { + svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + doc = new DOMParser().parseFromString( + new XMLSerializer().serializeToString(svg), + 'image/svg+xml', + ); + svg = doc.querySelector('svg'); + } if (!svg || doc.querySelector('parsererror')) { return { @@ -231,14 +285,15 @@ export function analyzeSvg(svg_content: string): SvgAnalysis { }; } + sanitiseTree(svg); const viewBox = svg.getAttribute('viewBox'); let width = parseNumber(svg.getAttribute('width')) ?? 0; let height = parseNumber(svg.getAttribute('height')) ?? 0; - if ((!width || !height) && viewBox) { + if (viewBox) { const parts = viewBox.split(/[\s,]+/).map(Number); if (parts.length === 4) { - width = width || parts[2]; - height = height || parts[3]; + width = parts[2] || width; + height = parts[3] || height; } } if (!width || !height) { @@ -253,16 +308,101 @@ export function analyzeSvg(svg_content: string): SvgAnalysis { // so the review step can never exclude a layer the objects don't share. const objects: ParsedObject[] = []; let without_id = 0; - - for (const el of Array.from( + const ids = new Set( + Array.from(svg.querySelectorAll('[id]')).map((el) => el.id), + ); + const host = document.createElement('div'); + host.style.cssText = + 'position:fixed;left:-100000px;top:0;visibility:hidden;pointer-events:none'; + const shadow = host.attachShadow({ mode: 'open' }); + const measured = document.importNode(svg, true) as unknown as SVGSVGElement; + measured.setAttribute('width', String(width)); + measured.setAttribute('height', String(height)); + shadow.appendChild(measured); + document.body.appendChild(host); + const viewport = measured.getCTM?.(); + const inverse = viewport ? invert(viewport) : null; + const origin = viewBox?.split(/[\s,]+/).map(Number) || [0, 0]; + const canvas_matrix = inverse + ? multiply(translation(-origin[0], -origin[1]), inverse) + : null; + const source_elements = Array.from( svg.querySelectorAll(INTERACTIVE_TAGS.join(', ')), - )) { - const svg_id = el.getAttribute('id'); - if (!svg_id) { - without_id++; + ); + const measured_elements = Array.from( + measured.querySelectorAll(INTERACTIVE_TAGS.join(', ')), + ); + + for (const [index, el] of source_elements.entries()) { + if (el.closest('defs, clipPath, mask, pattern, marker, symbol')) continue; + let svg_id = el.getAttribute('id'); + if (!svg_id) { + do { + svg_id = `imported-svg-${++without_id}`; + } while (ids.has(svg_id)); + ids.add(svg_id); + el.setAttribute('id', svg_id); + } + let geometry = extractGeometry(el); + let parentMatrix: ParsedObject['parentMatrix']; + let presentation: ParsedObject['presentation']; + const rendered = measured_elements[index] as SVGGraphicsElement; + if (typeof rendered.getBBox === 'function') { + try { + const style = getComputedStyle(rendered); + presentation = { + fill: style.fill || el.getAttribute('fill'), + stroke: style.stroke || el.getAttribute('stroke'), + opacity: Number( + style.opacity || el.getAttribute('opacity') || 1, + ), + }; + const box = rendered.getBBox(); + const ctm = rendered.getCTM(); + const matrix = + ctm && canvas_matrix ? multiply(canvas_matrix, ctm) : ctm; + const parent = + rendered.parentElement as unknown as SVGGraphicsElement; + const parent_ctm = parent.getCTM?.(); + const pm = + parent_ctm && canvas_matrix + ? multiply(canvas_matrix, parent_ctm) + : parent_ctm; + if (pm) + parentMatrix = { + a: pm.a, + b: pm.b, + c: pm.c, + d: pm.d, + e: pm.e, + 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), + }; + } + } catch { + /* Non-rendering SVG nodes have no bounding box. */ + } } - const geometry = extractGeometry(el); const tag = el.tagName.toLowerCase(); if (!isGeometryValid(geometry)) { issues.push({ @@ -277,13 +417,18 @@ export function analyzeSvg(svg_content: string): SvgAnalysis { label: extractLabel(el), geometry, layer: findParentLayer(el), + groupId: findParentGroup(el), attributes: extractAttributes(el), + parentMatrix, + presentation, }); } + host.remove(); + if (without_id > 0) { issues.push({ - message: `${without_id} shape element(s) have no id attribute and were skipped`, + message: `${without_id} SVG element(s) were assigned IDs for editing`, severity: 'info', }); } @@ -293,7 +438,8 @@ export function analyzeSvg(svg_content: string): SvgAnalysis { for (const group of Array.from(svg.querySelectorAll('g'))) { const id = group.getAttribute('id'); if (!id) continue; - const mapped = mapLayerName(id); + const mapped = LAYER_NAME_MAP[id.toLowerCase()]; + if (!mapped) continue; const names = group_names.get(mapped) ?? new Set(); names.add(group.getAttribute('inkscape:label') || id); group_names.set(mapped, names); @@ -324,5 +470,13 @@ export function analyzeSvg(svg_content: string): SvgAnalysis { }); } - return { width, height, viewBox, layers, objects, issues }; + return { + width, + height, + viewBox, + layers, + objects, + issues, + sourceSvg: new XMLSerializer().serializeToString(svg), + }; } diff --git a/apps/map-studio/src/app/data/types.ts b/apps/map-studio/src/app/data/types.ts index f9277f3f20b..055bb8e6f77 100644 --- a/apps/map-studio/src/app/data/types.ts +++ b/apps/map-studio/src/app/data/types.ts @@ -17,6 +17,8 @@ export interface Floorplan { floor_name: string; floor_index: number; level_zone_id?: string | null; + /** The level `map_id` this floorplan was last imported from or published to. */ + source_map_id?: string | null; source_image_path: string | null; source_type: string | null; background_opacity: number; @@ -26,6 +28,8 @@ export interface Floorplan { canvas_width: number | null; canvas_height: number | null; canvas_state: EditorState | null; + svg_source?: string | null; + svg_source_ids?: string[]; svg_output: string | null; status: 'draft' | 'published' | 'archived'; version: number; @@ -38,6 +42,8 @@ export interface FloorplanVersion { 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; diff --git a/apps/map-studio/src/app/editor/canvas.component.ts b/apps/map-studio/src/app/editor/canvas.component.ts index 888f3a7779a..4410726e916 100644 --- a/apps/map-studio/src/app/editor/canvas.component.ts +++ b/apps/map-studio/src/app/editor/canvas.component.ts @@ -28,6 +28,7 @@ import { computeSnap, getHandlePos, } from './geometry'; +import { composeSourceSvg, importedObject } from './source-svg'; interface DragState { id: string; @@ -168,6 +169,17 @@ const clamp01 = (value: number) => Math.max(0, Math.min(1, value)); /> } + @if (sourceImage(); as href) { + + } + @if (state.grid_enabled()) { Math.max(0, Math.min(1, value)); } } - @for (object of state.visible_objects(); track object.id) { + @for (object of drawnObjects(); track object.id) { @switch (object.geometry.type) { @case ('rect') { Math.max(0, Math.min(1, value)); stroke-dasharray="4 2" class="pointer-events-none" /> + @if (state.selected(); as object) { + @if ( + object.metadata?.['source'] === 'svg-import' && + state.floorplan()?.svg_source && + !object.locked + ) { + @for (handle of handles; track handle) { + + } + + } + } } @if (rect_draw(); as draw) { @@ -735,7 +776,49 @@ export class CanvasComponent { () => this.state.floorplan()?.background_color || '#ffffff', ); + public readonly drawnObjects = computed(() => + this.state + .visible_objects() + .filter( + (object) => + !this.state.floorplan()?.svg_source || + !importedObject(object), + ), + ); + + /** True while a drag, resize, rotate or vertex drag is under way. */ + private readonly _pointer_editing = computed( + () => + !!this._dragging() || + !!this._resizing() || + !!this._rotating() || + !!this._group_resizing() || + !!this._group_rotating() || + !!this._vertex_drag(), + ); + private _last_source_image: string | null = null; + + /** + * The imported map with object edits applied, as an image. It is held + * while a pointer edit is under way and composed again on release, since + * composing parses and serialises the whole source SVG. + */ + public readonly sourceImage = computed(() => { + const floor = this.state.floorplan(); + if (!floor?.svg_source) return (this._last_source_image = null); + if (this._pointer_editing() && this._last_source_image) + return this._last_source_image; + const visible = new Set( + this.state.visible_objects().map((object) => object.id), + ); + const objects = this.state + .objects() + .map((object) => ({ ...object, visible: visible.has(object.id) })); + return (this._last_source_image = `data:image/svg+xml,${encodeURIComponent(composeSourceSvg(floor.svg_source, objects, floor.svg_source_ids))}`); + }); + public readonly guideImage = computed(() => { + if (this.state.floorplan()?.svg_source) return null; const layer = this.state.layers().find((l) => l.id === 'guide'); return layer?.visible === false ? null : this.state.image_url(); }); @@ -753,7 +836,7 @@ export class CanvasComponent { ); public readonly roomOutlines = computed(() => - this.state.visible_objects().filter((o) => o.object_type === 'room'), + this.drawnObjects().filter((o) => o.object_type === 'room'), ); public readonly selectedRect = computed(() => { @@ -1816,7 +1899,11 @@ export class CanvasComponent { if (!hits.length) return null; const onActive = hits.filter((h) => h.onActive); const pool = onActive.length ? onActive : hits; - pool.sort((a, b) => a.area - b.area); + pool.sort((a, b) => + Math.abs(a.area - b.area) < 0.01 + ? b.object.z_index - a.object.z_index + : a.area - b.area, + ); return pool[0].object; } 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 aa6a1d8cce4..6325ee7f440 100644 --- a/apps/map-studio/src/app/editor/editor-state.service.ts +++ b/apps/map-studio/src/app/editor/editor-state.service.ts @@ -174,6 +174,8 @@ export class EditorStateService extends AsyncHandler { 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 image_url = this._image_url.asReadonly(); public readonly image_dims = this._image_dims.asReadonly(); public readonly place_size = this._place_size.asReadonly(); @@ -211,7 +213,9 @@ export class EditorStateService extends AsyncHandler { public readonly canvas_width = computed(() => Math.max( 100, - this._image_dims()?.width ?? + (this._floorplan()?.svg_source + ? this._floorplan()?.canvas_width + : this._image_dims()?.width) ?? this._floorplan()?.canvas_width ?? 1000, ), @@ -220,7 +224,9 @@ export class EditorStateService extends AsyncHandler { public readonly canvas_height = computed(() => Math.max( 100, - this._image_dims()?.height ?? + (this._floorplan()?.svg_source + ? this._floorplan()?.canvas_height + : this._image_dims()?.height) ?? this._floorplan()?.canvas_height ?? 800, ), @@ -239,6 +245,41 @@ export class EditorStateService extends AsyncHandler { // ── Loading ───────────────────────────────────────────────────────────── + public readonly can_reimport_svg = computed(() => { + const floor = this._floorplan(); + return ( + !!floor?.svg_output && + !floor.svg_source && + (floor.source_type === 'svg-import' || + floor.source_type === 'image/svg+xml') + ); + }); + + public async reimportSvg() { + if ( + !this.can_reimport_svg() || + !confirm( + 'Reimport the original SVG? This replaces the current draft.', + ) + ) + return; + this._loading.set(true); + try { + const image = this._image_url(); + const source = image + ? await fetch(image).then((response) => response.text()) + : this._floorplan()?.svg_output; + if (!source) throw new Error('The original SVG is unavailable.'); + await this._store.importLevelSvg(this._floorplan_id, source); + await this.load(this._floorplan_id); + await this.save(); + } catch (error) { + this._error.set(this._message(error, 'Failed to reimport SVG')); + } finally { + this._loading.set(false); + } + } + public async load(floorplan_id: string) { this._floorplan_id = floorplan_id; this._loading.set(true); @@ -264,18 +305,17 @@ export class EditorStateService extends AsyncHandler { let objects = await this._store.listObjects(floorplan_id); let imported_svg = false; const svg_check_key = `map-studio.svg-import.${floorplan_id}`; - if ( - floorplan.level_zone_id && + const level_id = floorplan.level_zone_id; + const empty = !floorplan.canvas_state && !floorplan.svg_output && !floorplan.source_image_path && - !objects.length && - !localStorage.getItem(svg_check_key) - ) { - const svg = await this._placeos - .getLevelSvg(floorplan.level_zone_id) + !objects.length; + if (level_id && empty && !localStorage.getItem(svg_check_key)) { + const level_map = await this._placeos + .getLevelMap(level_id) .catch(() => null); - if (svg) { + if (level_map) { localStorage.setItem(svg_check_key, 'checked'); if ( confirm( @@ -284,10 +324,42 @@ export class EditorStateService extends AsyncHandler { ) { floorplan = await this._store.importLevelSvg( floorplan_id, - svg, + level_map.svg, + level_map.map_id, + ); + objects = await this._store.listObjects(floorplan_id); + imported_svg = true; + } + } + } else if ( + level_id && + !empty && + this._placeos.mode() === 'domain' + ) { + const map_id = await this._placeos + .getZone(level_id) + .then((zone) => zone.map_id) + .catch(() => ''); + if (map_id && map_id !== floorplan.source_map_id) { + const replace = confirm( + "This level's map in PlaceOS is not the one this floorplan was made from. Import the current map?\n\nThis replaces the Studio draft. Cancel keeps the draft.", + ); + const level_map = replace + ? await this._placeos.getLevelMap(level_id) + : null; + if (level_map) { + floorplan = await this._store.importLevelSvg( + floorplan_id, + level_map.svg, + level_map.map_id, ); objects = await this._store.listObjects(floorplan_id); imported_svg = true; + } else if (!replace) { + floorplan = await this._store.updateFloorplan( + floorplan_id, + { source_map_id: map_id }, + ); } } } @@ -310,6 +382,24 @@ export class EditorStateService extends AsyncHandler { } } + const known_layers = new Set( + this._layers().map((layer) => layer.id), + ); + const missing_layers = [ + ...new Set(objects.map((object) => object.layer)), + ].filter((id) => !known_layers.has(id)); + if (missing_layers.length) + this._layers.update((layers) => [ + ...layers, + ...missing_layers.map((id, index) => ({ + id, + name: id, + visible: true, + locked: false, + opacity: 1, + order: layers.length + index, + })), + ]); await this._loadImage(floorplan_id); if (imported_svg) await this.save(); } catch (error) { diff --git a/apps/map-studio/src/app/editor/editor.component.ts b/apps/map-studio/src/app/editor/editor.component.ts index 7d5045b69df..8e824a30dee 100644 --- a/apps/map-studio/src/app/editor/editor.component.ts +++ b/apps/map-studio/src/app/editor/editor.component.ts @@ -243,6 +243,14 @@ const TOOLS: { id: Tool; label: string; key: string; icon: string }[] = [ (change)="onUpload($event)" /> + @if (state.can_reimport_svg()) { + + } @if (ai_configured) { @if (canvas()?.containerEl(); as container) { - + } diff --git a/apps/map-studio/src/app/editor/minimap.component.ts b/apps/map-studio/src/app/editor/minimap.component.ts index ab219ff83b4..33b571da66a 100644 --- a/apps/map-studio/src/app/editor/minimap.component.ts +++ b/apps/map-studio/src/app/editor/minimap.component.ts @@ -13,6 +13,7 @@ import { IconComponent } from '@placeos/components'; import { MapObject } from '../data/types'; import { EditorStateService } from './editor-state.service'; +import { importedObject } from './source-svg'; const TYPE_COLORS: Record = { room: '#3b82f6', @@ -70,7 +71,15 @@ const clamp = (value: number, min: number, max: number) => state.canvas_height() " > - @if (state.image_url(); as href) { + @if (source_image(); as href) { + + } @else if (state.image_url(); as href) { opacity="0.7" /> } - @for (object of state.visible_objects(); track object.id) { + @for (object of drawnObjects(); track object.id) { @switch (object.geometry.type) { @case ('rect') { (); + public readonly source_image = input(null); + public readonly drawnObjects = computed(() => + this.state + .visible_objects() + .filter( + (object) => !this.source_image() || !importedObject(object), + ), + ); private readonly _frame = viewChild>('frame'); 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 896c37f933a..c93613a9e29 100644 --- a/apps/map-studio/src/app/editor/publish-panel.component.ts +++ b/apps/map-studio/src/app/editor/publish-panel.component.ts @@ -6,6 +6,7 @@ import { PlaceOSSystem, PlaceOSZone, } from '../data/placeos.service'; +import { StoreService } from '../data/store.service'; import { EditorStateService } from './editor-state.service'; import { bookableObjects, buildSvg, preflightChecks } from './svg-export'; import { ToastService } from './toast.service'; @@ -169,6 +170,7 @@ export class PublishPanelComponent { public readonly state = inject(EditorStateService); public readonly placeos = inject(PlaceOSService); private readonly _toasts = inject(ToastService); + private readonly _store = inject(StoreService); public readonly buildings = signal([]); public readonly levels = signal([]); @@ -242,6 +244,8 @@ export class PublishPanelComponent { this.state.canvas_width(), this.state.canvas_height(), this.state.floorplan()?.background_color || '#ffffff', + this.state.floorplan()?.svg_source, + this.state.floorplan()?.svg_source_ids, ); } @@ -277,6 +281,12 @@ export class PublishPanelComponent { await this.placeos.updateZone(level, { map_id: result.file_url, }); + const floorplan = this.state.floorplan(); + if (floorplan?.level_zone_id === level) { + await this._store.updateFloorplan(floorplan.id, { + source_map_id: result.file_url, + }); + } this.status.set('Published'); this._toasts.show('Published to PlaceOS', 'success'); } catch (error) { diff --git a/apps/map-studio/src/app/editor/source-svg.ts b/apps/map-studio/src/app/editor/source-svg.ts new file mode 100644 index 00000000000..cf1781890b2 --- /dev/null +++ b/apps/map-studio/src/app/editor/source-svg.ts @@ -0,0 +1,218 @@ +import { + IDENTITY, + invert, + Matrix, + multiply, + parseTransform, + sanitiseTree, +} from '@placeos/map-editor'; +import { MapObject, ObjectGeometry } from '../data/types'; + +export function importedObject(object: MapObject): boolean { + return object.metadata?.['source'] === 'svg-import'; +} + +/** Size an SVG image to the editor's logical viewport. */ +export function sourceImageUrl( + svg: string, + width: number, + height: number, +): string { + const root = new DOMParser().parseFromString(svg, 'image/svg+xml') + .documentElement as unknown as SVGSVGElement; + root.setAttribute('width', String(width)); + root.setAttribute('height', String(height)); + root.style.width = `${width}px`; + root.style.height = `${height}px`; + return `data:image/svg+xml,${encodeURIComponent(new XMLSerializer().serializeToString(root))}`; +} + +function idSelector(id: string) { + return `#${id.replace(/[^a-zA-Z0-9_-]/g, (character) => `\\${character}`)}`; +} + +function selectorPattern(id: string, selector_only = false) { + const escaped = idSelector(id).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp( + `${escaped}(?![\\w.-])${selector_only ? '(?=[^{}]*\\{)' : ''}`, + 'g', + ); +} + +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; + if ( + original && + JSON.stringify(original) !== JSON.stringify(object.geometry) + ) { + const before = bounds(original); + const after = bounds(object.geometry); + const sx = before.width ? after.width / before.width : 1; + const sy = before.height ? after.height / before.height : 1; + let delta: Matrix = { + a: sx, + b: 0, + c: 0, + d: sy, + e: after.x - before.x * sx, + f: after.y - before.y * sy, + }; + if (object.geometry.rotation) { + const cx = after.x + after.width / 2; + const cy = after.y + after.height / 2; + delta = multiply( + parseTransform( + `rotate(${object.geometry.rotation} ${cx} ${cy})`, + ), + delta, + ); + } + const parent = (metadata['parentMatrix'] as Matrix) || IDENTITY; + const inverse = invert(parent); + if (inverse) { + const original_attributes = + (metadata['originalAttributes'] as Record) || + {}; + const matrix = multiply( + multiply(multiply(inverse, delta), parent), + parseTransform(original_attributes['transform']), + ); + element.setAttribute( + 'transform', + `matrix(${matrix.a} ${matrix.b} ${matrix.c} ${matrix.d} ${matrix.e} ${matrix.f})`, + ); + } + } + for (const [attribute, value, baseline] of [ + ['fill', object.fill_color, metadata['originalFill'] ?? null], + ['stroke', object.stroke_color, metadata['originalStroke'] ?? null], + ['opacity', object.opacity, metadata['originalOpacity'] ?? 1], + ] as const) { + if (value != null && value !== baseline) { + element.setAttribute(attribute, String(value)); + (element as SVGElement).style.setProperty(attribute, String(value)); + } + } + if (object.label !== metadata['originalLabel']) { + element.setAttribute('data-label', object.label || ''); + if (element.localName === 'text') + element.textContent = object.label || ''; + } + if (object.svg_id) element.setAttribute('id', object.svg_id); + if (!object.visible) + (element as SVGElement).style.setProperty('display', 'none'); +} + +/** Patch imported objects inside their original SVG hierarchy. */ +export function composeSourceSvg( + source: string, + objects: MapObject[], + managed_ids?: string[], +): string { + const doc = new DOMParser().parseFromString(source, 'image/svg+xml'); + const root = doc.documentElement; + if (root.localName !== 'svg' || doc.querySelector('parsererror')) + throw new Error('Invalid imported SVG'); + sanitiseTree(root); + const by_source = new Map(); + for (const object of objects.filter(importedObject)) { + const attributes = object.metadata?.['originalAttributes'] as + | Record + | undefined; + const id = attributes?.['id'] || object.svg_id; + if (!id) continue; + by_source.set(id, [...(by_source.get(id) || []), object]); + } + const ids = managed_ids || [...by_source.keys()]; + const renames = new Map(); + for (const id of ids) { + const element = doc.getElementById(id); + if (!element) continue; + const matching = (by_source.get(id) || []).sort( + (a, b) => Number(b.svg_id === id) - Number(a.svg_id === id), + ); + if (!matching.length) { + element.remove(); + continue; + } + const template = element.cloneNode(true) as Element; + matching.forEach((object, index) => { + const target = index + ? (template.cloneNode(true) as Element) + : element; + if (index) + element.parentNode?.insertBefore(target, element.nextSibling); + if (index && object.svg_id) { + for (const style of Array.from( + root.querySelectorAll('style'), + )) { + style.textContent = (style.textContent || '').replace( + selectorPattern(id, true), + `:is(${idSelector(id)},${idSelector(object.svg_id)})`, + ); + } + } + patchObject(target, object); + if (!index && object.svg_id && object.svg_id !== id) + renames.set(id, object.svg_id); + }); + } + for (const [old_id, new_id] of renames) { + const selector = selectorPattern(old_id); + for (const element of Array.from(root.querySelectorAll('*'))) { + if (element.localName === 'style') + element.textContent = (element.textContent || '').replace( + selector, + idSelector(new_id), + ); + for (const attribute of Array.from(element.attributes)) { + const value = attribute.value.replaceAll( + `url(#${old_id})`, + `url(#${new_id})`, + ); + if (attribute.localName === 'href' && value === `#${old_id}`) + element.setAttributeNS( + attribute.namespaceURI, + attribute.name, + `#${new_id}`, + ); + else if (value !== attribute.value) + element.setAttributeNS( + attribute.namespaceURI, + attribute.name, + value, + ); + } + } + } + return new XMLSerializer().serializeToString(root); +} diff --git a/apps/map-studio/src/app/editor/svg-export.ts b/apps/map-studio/src/app/editor/svg-export.ts index 24dd054a0d8..8e06dd0c14d 100644 --- a/apps/map-studio/src/app/editor/svg-export.ts +++ b/apps/map-studio/src/app/editor/svg-export.ts @@ -1,5 +1,6 @@ -import { MapObject } from '../data/types'; +import { MapObject, ObjectGeometry } from '../data/types'; import { TYPE_COLORS } from './constants'; +import { composeSourceSvg, importedObject } from './source-svg'; /** Order objects are painted in, back to front */ const TYPE_ORDER = [ @@ -137,19 +138,64 @@ function escapeXml(value: string): string { .replace(/'/g, '''); } +/** Geometry that carries a full bounding box */ +type Bounded = Required>; + +const hasBounds = (g: ObjectGeometry): g is ObjectGeometry & Bounded => + [g.x, g.y, g.width, g.height].every((v) => typeof v === 'number'); + +/** Label centred in a box, sized to fit inside it */ +function boxLabel(label: string, box: Bounded): string { + const size = Math.max(8, Math.min(box.width / 6, box.height / 3, 16)); + return `${label}`; +} + /** - * Builds a clean, PlaceOS-ready SVG from the map objects. - * - * Objects are grouped by type and each carries its `svg_id`, so PlaceOS can - * bind bookings to elements by id. The background image is deliberately left - * out — the export is vector only. + * Export imported SVGs in their source hierarchy; build shapes for new maps. + * PlaceOS viewers paint availability on the element carrying a shape's id. */ export function buildSvg( objects: MapObject[], width: number, height: number, background_color = '#ffffff', + source?: string | null, + managed_ids?: string[], ): string { + if (source) { + const doc = new DOMParser().parseFromString( + composeSourceSvg(source, objects, managed_ids), + 'image/svg+xml', + ); + const added = objects.filter((object) => !importedObject(object)); + if (added.length) { + const additions = new DOMParser().parseFromString( + buildSvg(added, width, height, background_color), + 'image/svg+xml', + ); + const group = doc.createElementNS( + 'http://www.w3.org/2000/svg', + 'g', + ); + const viewBox = doc.documentElement + .getAttribute('viewBox') + ?.split(/[ ,]+/) + .map(Number); + if (viewBox?.length === 4) + group.setAttribute( + 'transform', + `translate(${viewBox[0]} ${viewBox[1]})`, + ); + for (const child of Array.from( + additions.documentElement.children, + ).slice(1)) { + child.removeAttribute('id'); + group.appendChild(doc.importNode(child, true)); + } + doc.documentElement.appendChild(group); + } + return new XMLSerializer().serializeToString(doc.documentElement); + } const lines: string[] = [ '', ``, @@ -165,62 +211,63 @@ export function buildSvg( lines.push(` `); for (const object of matching) { const g = object.geometry; - const fill = exportFill(object); const stroke = exportStroke(object); - const stroke_width = exportStrokeWidth(object); const label = escapeXml(object.label ?? ''); const id = escapeXml(idOf(object)); - const rotation = g.rotation - ? ` transform="rotate(${g.rotation} ${(g.x ?? 0) + (g.width ?? 0) / 2} ${(g.y ?? 0) + (g.height ?? 0) / 2})"` - : ''; + const style = `fill="${exportFill(object)}" stroke="${stroke}" stroke-width="${exportStrokeWidth(object)}" data-type="${object.object_type}" data-label="${label}"`; + let shape = ''; + let text = ''; + let transform = ''; if (g.type === 'rect') { - const x = g.x ?? 0; - const y = g.y ?? 0; - const w = g.width ?? 50; - const h = g.height ?? 50; - lines.push(` `); - lines.push( - ` `, - ); + const box = { + x: g.x ?? 0, + y: g.y ?? 0, + width: g.width ?? 50, + height: g.height ?? 50, + }; + if (g.rotation) { + transform = ` transform="rotate(${g.rotation} ${(g.x ?? 0) + (g.width ?? 0) / 2} ${(g.y ?? 0) + (g.height ?? 0) / 2})"`; + } + shape = ``; if (label && object.object_type !== 'decorative') { - const size = Math.max(8, Math.min(w / 6, h / 3, 16)); - lines.push( - ` ${label}`, - ); + text = boxLabel(label, box); } - lines.push(' '); } else if (g.type === 'polygon' && g.points?.length) { const points = g.points.map((p) => `${p.x},${p.y}`).join(' '); const cx = g.points.reduce((sum, p) => sum + p.x, 0) / g.points.length; const cy = g.points.reduce((sum, p) => sum + p.y, 0) / g.points.length; - lines.push(` `); - lines.push( - ` `, - ); + shape = ``; if (label) { - lines.push( - ` ${label}`, - ); + text = `${label}`; } - lines.push(' '); } else if (g.type === 'circle') { const cx = g.x ?? 0; const cy = g.y ?? 0; const r = g.r ?? 10; - lines.push(` `); - lines.push( - ` `, - ); + shape = ``; if (label) { - lines.push( - ` ${label}`, - ); + text = `${label}`; + } + } else if (g.type === 'path' && g.d) { + shape = ``; + if ( + label && + object.object_type !== 'decorative' && + hasBounds(g) + ) { + text = boxLabel(label, g); } - lines.push(' '); + } else { + continue; } + + lines.push(` `); + lines.push(` ${shape}`); + if (text) lines.push(` ${text}`); + lines.push(' '); } lines.push(' '); } 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 18367e3f92a..e5ceb8fc15e 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 @@ -2,6 +2,7 @@ import { Component, computed, input, signal } from '@angular/core'; import { AvailabilityState, Floorplan, MapObject } from '../data/types'; import { STATE_COLORS } from '../editor/availability'; +import { composeSourceSvg, importedObject } from '../editor/source-svg'; import { computeHeatmap } from './heatmap'; const STATUS_FILLS: Record = { @@ -58,7 +59,18 @@ const DEFAULT_STROKE = '#475569'; [attr.fill]="background()" /> - @if (image_url(); as href) { + @if (sourceImage(); as href) { + + } + + @if (!floorplan().svg_source && image_url(); as href) { { + const floor = this.floorplan(); + if (!floor.svg_source) return null; + const objects = this.objects().map((object) => + this.availability()[object.id] + ? { + ...object, + fill_color: this.fillOf(object), + stroke_color: this.strokeOf(object), + } + : object, + ); + return `data:image/svg+xml,${encodeURIComponent(composeSourceSvg(floor.svg_source, objects, floor.svg_source_ids))}`; + }); + private readonly _visible = computed(() => - this.objects().filter((o) => o.visible), + this.objects().filter( + (o) => + o.visible && + (!this.floorplan().svg_source || !importedObject(o)), + ), ); public readonly walls = computed(() => diff --git a/apps/map-studio/src/app/pages/projects.component.ts b/apps/map-studio/src/app/pages/projects.component.ts index 518eb45e749..696c337ba99 100644 --- a/apps/map-studio/src/app/pages/projects.component.ts +++ b/apps/map-studio/src/app/pages/projects.component.ts @@ -1,4 +1,4 @@ -import { Component, inject, signal } from '@angular/core'; +import { Component, effect, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormField } from '@angular/material/form-field'; import { MatInput } from '@angular/material/input'; @@ -216,7 +216,10 @@ export class ProjectsComponent { public readonly form_building = signal(''); constructor() { - this.load(); + effect(() => { + this._store.zone_sync_version(); + void this.load(); + }); } public readonly floorCount = (project: Project) => 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 9d65990f7b2..fdd184b1dc8 100644 --- a/apps/map-studio/src/tests/editor-state.service.spec.ts +++ b/apps/map-studio/src/tests/editor-state.service.spec.ts @@ -71,6 +71,32 @@ describe('EditorStateService', () => { expect(editor.error()).toBe(''); }); + it('requires confirmation before replacing a legacy SVG draft', async () => { + await store.updateFloorplan(floorplan_id, { + source_type: 'svg-import', + svg_output: + '', + }); + await editor.load(floorplan_id); + const imported = vi + .spyOn(store, 'importLevelSvg') + .mockResolvedValue(await store.getFloorplan(floorplan_id)); + const confirm_import = vi.fn(() => false); + vi.stubGlobal('confirm', confirm_import); + await editor.reimportSvg(); + expect(imported).not.toHaveBeenCalled(); + expect(editor.objects()).toHaveLength(1); + confirm_import.mockReturnValue(true); + vi.spyOn(editor, 'load').mockResolvedValue(undefined); + vi.spyOn(editor, 'save').mockResolvedValue(undefined); + await editor.reimportSvg(); + expect(imported).toHaveBeenCalledWith( + floorplan_id, + expect.stringContaining(' { const server = { floor_name: 'Server Level', @@ -106,8 +132,8 @@ describe('EditorStateService', () => { const svg = ''; vi.spyOn( TestBed.inject(PlaceOSService), - 'getLevelSvg', - ).mockResolvedValue(svg); + 'getLevelMap', + ).mockResolvedValue({ map_id: '/maps/level-2.svg', svg }); const imported = vi .spyOn(store, 'importLevelSvg') .mockResolvedValue({ ...floorplan, svg_output: svg }); @@ -119,13 +145,72 @@ describe('EditorStateService', () => { expect(confirm_import).toHaveBeenCalledWith( expect.stringContaining('already has an SVG map'), ); - expect(imported).toHaveBeenCalledWith(floorplan.id, svg); + expect(imported).toHaveBeenCalledWith( + floorplan.id, + svg, + '/maps/level-2.svg', + ); expect( localStorage.getItem(`map-studio.svg-import.${floorplan.id}`), ).toBe('checked'); vi.unstubAllGlobals(); }); + it("offers the level's current map when it is not the one the floorplan came from", async () => { + const placeos = TestBed.inject(PlaceOSService); + vi.spyOn(placeos, 'mode').mockReturnValue('domain'); + vi.spyOn(placeos, 'saveFloorplanMetadata').mockResolvedValue(undefined); + vi.spyOn(store, 'getNewerServerFloorplan').mockResolvedValue(null); + const floorplan = await store.addFloorplan( + 'p1', + 'Level 3', + 2, + 'level-3', + { + source_map_id: '/maps/old.svg', + }, + ); + await store.createObject(floorplan.id, { label: 'Desk' }); + vi.spyOn(placeos, 'getZone').mockResolvedValue({ + map_id: '/maps/new.svg', + } as never); + const svg = ''; + vi.spyOn(placeos, 'getLevelMap').mockResolvedValue({ + map_id: '/maps/new.svg', + svg, + }); + const imported = vi + .spyOn(store, 'importLevelSvg') + .mockResolvedValue({ ...floorplan, svg_output: svg }); + const ask = vi.fn(() => false); + vi.stubGlobal('confirm', ask); + + await editor.load(floorplan.id); + expect(ask).toHaveBeenCalledWith( + expect.stringContaining('not the one this floorplan was made from'), + ); + expect(imported).not.toHaveBeenCalled(); + expect((await store.getFloorplan(floorplan.id)).source_map_id).toBe( + '/maps/new.svg', + ); + + ask.mockClear(); + await editor.load(floorplan.id); + expect(ask).not.toHaveBeenCalled(); + + await store.updateFloorplan(floorplan.id, { + source_map_id: '/maps/old.svg', + }); + ask.mockReturnValue(true); + await editor.load(floorplan.id); + expect(imported).toHaveBeenCalledWith( + floorplan.id, + svg, + '/maps/new.svg', + ); + vi.unstubAllGlobals(); + }); + it('starts with nothing to undo', () => { expect(editor.can_undo()).toBe(false); expect(editor.can_redo()).toBe(false); diff --git a/apps/map-studio/src/tests/placeos.service.spec.ts b/apps/map-studio/src/tests/placeos.service.spec.ts index 499b299ba7a..e0b2258f6f3 100644 --- a/apps/map-studio/src/tests/placeos.service.spec.ts +++ b/apps/map-studio/src/tests/placeos.service.spec.ts @@ -1,4 +1,5 @@ vi.mock('@placeos/ts-client', () => ({ + authorise: vi.fn(async () => ''), authority: () => ({ domain: 'place.example.com', config: { @@ -9,18 +10,47 @@ vi.mock('@placeos/ts-client', () => ({ }, }, }), + setToken: vi.fn(), setup: vi.fn(async () => undefined), showMetadata: vi.fn(async () => ({ details: {} })), - token: () => 'session-token', + token: vi.fn(() => 'session-token'), updateMetadata: vi.fn(async () => undefined), })); import { provideZonelessChangeDetection } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { setup, showMetadata, updateMetadata } from '@placeos/ts-client'; +import { + authorise, + setToken, + setup, + showMetadata, + token, + updateMetadata, +} from '@placeos/ts-client'; import { PlaceOSService } from '../app/data/placeos.service'; +/** Makes this window look opened by another tab that answers token requests with `reply`. */ +function openedBy(reply: Record | null) { + const frame = document.createElement('iframe'); + document.body.appendChild(frame); + const opener = frame.contentWindow as Window; + vi.spyOn(opener, 'postMessage').mockImplementation(() => { + if (!reply) return; + window.dispatchEvent( + new MessageEvent('message', { + source: opener, + origin: `http://${location.hostname}:4200`, + data: { type: 'placeos:token', ...reply }, + }), + ); + }); + Object.defineProperty(window, 'opener', { + value: opener, + configurable: true, + }); +} + /** Records the last request so the auth headers can be checked */ function stubFetch(authority_ok: boolean) { const calls: [string, RequestInit][] = []; @@ -64,13 +94,22 @@ describe('PlaceOSService', () => { vi.clearAllMocks(); localStorage.clear(); vi.mocked(setup).mockResolvedValue(undefined); + vi.mocked(token).mockReturnValue('session-token'); + vi.mocked(setToken).mockImplementation(() => undefined); TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection()], }); service = TestBed.inject(PlaceOSService); }); - afterEach(() => vi.unstubAllGlobals()); + afterEach(() => { + vi.unstubAllGlobals(); + Object.defineProperty(window, 'opener', { + value: null, + configurable: true, + }); + document.body.innerHTML = ''; + }); it('uses the domain session when the authority resolves', async () => { const calls = stubFetch(true); @@ -106,6 +145,63 @@ describe('PlaceOSService', () => { vi.useRealTimers(); }); + it('keeps the response readable until its JSON body has finished', async () => { + stubFetch(true); + await service.init(); + let signal: AbortSignal; + vi.stubGlobal( + 'fetch', + vi.fn(async (_url, options: RequestInit) => { + signal = options.signal as AbortSignal; + return { + ok: true, + json: async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + if (signal.aborted) + throw new DOMException('Aborted', 'AbortError'); + return [{ id: 'org-2' }]; + }, + }; + }), + ); + + await expect(service.getZones()).resolves.toEqual([{ id: 'org-2' }]); + expect(signal!.aborted).toBe(true); + }); + + it('does not start domain requests while login is redirecting', async () => { + stubFetch(true); + vi.mocked(token).mockReturnValue(''); + await service.init(); + expect(service.mode()).toBe('checking'); + expect(authorise).toHaveBeenCalled(); + }); + + it('signs in with the token of the app that opened it', async () => { + stubFetch(true); + const expires_at = Date.now() + 60 * 60 * 1000; + let current = ''; + vi.mocked(token).mockImplementation(() => current); + vi.mocked(setToken).mockImplementation((value) => (current = value)); + openedBy({ token: 'concierge-token', expires_at }); + await service.init(); + expect(setup).toHaveBeenCalledWith( + expect.objectContaining({ handle_login: true }), + ); + expect(setToken).toHaveBeenCalledWith('concierge-token', expires_at); + expect(authorise).not.toHaveBeenCalled(); + expect(service.mode()).toBe('domain'); + }); + + it('goes to login when the opener hands over an expired token', async () => { + stubFetch(true); + vi.mocked(token).mockReturnValue(''); + openedBy({ token: 'stale', expires_at: Date.now() - 1000 }); + await service.init(); + expect(setToken).not.toHaveBeenCalled(); + expect(authorise).toHaveBeenCalled(); + }); + it('falls back to manual setup when PlaceOS setup does not settle', async () => { vi.useFakeTimers(); stubFetch(true); diff --git a/apps/map-studio/src/tests/source-svg.spec.ts b/apps/map-studio/src/tests/source-svg.spec.ts new file mode 100644 index 00000000000..b5e84b360ec --- /dev/null +++ b/apps/map-studio/src/tests/source-svg.spec.ts @@ -0,0 +1,167 @@ +import { analyzeSvg } from '../app/data/svg-import'; +import { MapObject } from '../app/data/types'; +import { buildSvg } from '../app/editor/svg-export'; + +const source = `Office 101`; +function imported() { + const analysis = analyzeSvg(source); + const objects = analysis.objects.map( + (parsed, index) => + ({ + id: `object-${index}`, + floorplan_id: 'floor', + object_type: parsed.suggestedType, + svg_id: parsed.svgId, + label: parsed.label || parsed.svgId, + geometry: parsed.geometry, + entity_type: null, + entity_id: null, + layer: parsed.layer, + fill_color: parsed.attributes['fill'] || null, + stroke_color: parsed.attributes['stroke'] || null, + opacity: Number(parsed.attributes['opacity'] ?? 1), + capacity: null, + amenities: null, + tags: null, + metadata: { + source: 'svg-import', + originalAttributes: parsed.attributes, + originalGeometry: parsed.geometry, + originalLabel: parsed.label || parsed.svgId, + originalFill: parsed.attributes['fill'] || null, + originalStroke: parsed.attributes['stroke'] || null, + originalOpacity: Number(parsed.attributes['opacity'] ?? 1), + parentMatrix: parsed.parentMatrix, + }, + group_id: null, + z_index: index, + locked: false, + visible: true, + }) as MapObject, + ); + return { + analysis, + objects, + export: (items = objects) => + buildSvg( + items, + 200, + 100, + '#fff', + analysis.sourceSvg, + analysis.objects.map((p) => p.svgId), + ), + }; +} +const parse = (value: string) => + new DOMParser().parseFromString(value, 'image/svg+xml'); + +describe('imported SVG preservation', () => { + it('round-trips detailed artwork without reconstructing it', () => { + const fixture = imported(); + expect(fixture.objects).toHaveLength(6); + const output = fixture.export(); + expect(output).toBe(fixture.analysis.sourceSvg); + const doc = parse(output); + expect(doc.querySelector('ellipse')?.getAttribute('ry')).toBe('3'); + expect(doc.querySelector('polyline')).not.toBeNull(); + expect(doc.querySelector('text tspan')?.textContent).toBe('101'); + expect( + doc + .querySelector('use') + ?.getAttributeNS('http://www.w3.org/1999/xlink', 'href'), + ).toBe('#symbol'); + expect(doc.querySelector('g')?.getAttribute('transform')).toBe( + 'translate(20 10)', + ); + }); + + it('changes only the selected paint and preserves inherited CSS and other artwork', () => { + const fixture = imported(); + fixture.objects[0].fill_color = '#123456'; + const doc = parse(fixture.export()); + expect( + (doc.getElementById('room-1') as unknown as SVGElement).style.fill, + ).toBe('#123456'); + expect(doc.querySelector('defs')).not.toBeNull(); + expect(doc.querySelector('text')?.textContent).toBe('Office 101'); + expect(doc.querySelector('ellipse')?.outerHTML).toBe( + parse(fixture.analysis.sourceSvg!).querySelector('ellipse') + ?.outerHTML, + ); + }); + + it('deletes an imported shape without deleting its siblings or definitions', () => { + const fixture = imported(); + const doc = parse(fixture.export(fixture.objects.slice(1))); + expect(doc.getElementById('room-1')).toBeNull(); + expect(doc.querySelector('linearGradient')).not.toBeNull(); + expect(doc.querySelector('ellipse')).not.toBeNull(); + }); + + it('moves an object in canvas coordinates under a scaled parent', () => { + const fixture = imported(); + const object = fixture.objects[0]; + object.metadata!['parentMatrix'] = { + a: 2, + b: 0, + c: 0, + d: 2, + e: 20, + f: 10, + }; + object.geometry = { + ...object.geometry, + x: (object.geometry.x || 0) + 10, + }; + expect( + parse(fixture.export()) + .getElementById('room-1') + ?.getAttribute('transform'), + ).toBe('matrix(1 0 0 1 5 0)'); + }); + + it('keeps duplicate artwork and new furniture in the exported map', () => { + const fixture = imported(); + const copy = structuredClone(fixture.objects[0]); + copy.id = 'copy'; + copy.svg_id = 'room-copy'; + copy.geometry.x = 60; + const addition = { + ...copy, + id: 'new', + svg_id: 'desk-new', + metadata: null, + object_type: 'desk' as const, + }; + const doc = parse(fixture.export([...fixture.objects, copy, addition])); + expect(doc.getElementById('room-copy')).not.toBeNull(); + expect(doc.querySelector('style')?.textContent).toContain( + ':is(#room-1,#room-copy)', + ); + expect(doc.getElementById('desk-new')?.localName).toBe('rect'); + const ids = Array.from(doc.querySelectorAll('[id]')).map((el) => el.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('removes scripted content before rendering or exporting', () => { + const analysis = analyzeSvg( + '', + ); + expect(analysis.sourceSvg).not.toContain('script'); + expect(analysis.sourceSvg).not.toContain('onclick'); + }); + + it('keeps selector styling and use references when an imported ID changes', () => { + const fixture = imported(); + fixture.objects[0].svg_id = 'room-renamed'; + const doc = parse(fixture.export()); + expect(doc.getElementById('room-1')).toBeNull(); + expect(doc.querySelector('style')?.textContent).toContain( + '#room-renamed', + ); + expect(doc.querySelector('style')?.textContent).toContain( + 'url(#shade)', + ); + }); +}); diff --git a/apps/map-studio/src/tests/store.service.spec.ts b/apps/map-studio/src/tests/store.service.spec.ts index d8f965cedc2..d903f6eab7a 100644 --- a/apps/map-studio/src/tests/store.service.spec.ts +++ b/apps/map-studio/src/tests/store.service.spec.ts @@ -58,6 +58,15 @@ describe('StoreService', () => { saveFloorplanMetadata: vi.fn( async (): Promise => undefined, ), + getZone: vi.fn(async (id: string) => + id === 'building-1' + ? { id, name: 'Sydney HQ', display_name: 'Sydney HQ' } + : { id, name: id, display_name: id, parent_id: 'building-1' }, + ), + getZones: vi.fn(async () => [ + { id: 'level-1', name: 'Level 1', display_name: 'Level 1' }, + { id: 'level-2', name: 'Level 2', display_name: 'Level 2' }, + ]), }; beforeEach(() => { @@ -178,6 +187,57 @@ describe('StoreService', () => { ).resolves.toBeNull(); }); + it("finds a level's floorplan, adding its building's floors once even while another sync runs", async () => { + const zone = (id: string, name: string) => ({ + id, + name, + display_name: name, + }); + const [, floorplan] = await Promise.all([ + store.syncPlaceOSZones([ + { + zone: zone('building-1', 'Sydney HQ'), + levels: [ + zone('level-1', 'Level 1'), + zone('level-2', 'Level 2'), + ], + }, + ]), + store.floorplanForLevel('level-2'), + ]); + + expect(floorplan.level_zone_id).toBe('level-2'); + const projects = await store.listProjects(); + expect(projects).toHaveLength(1); + expect(projects[0].floorplans).toHaveLength(2); + await expect(store.floorplanForLevel('level-2')).resolves.toMatchObject( + { id: floorplan.id }, + ); + }); + + it('keeps a save in this browser when PlaceOS refuses its copy', async () => { + const floorplan = await store.addFloorplan( + 'project-1', + 'Level 1', + 0, + 'level-1', + ); + placeos.saveFloorplanMetadata.mockRejectedValueOnce({ status: 413 }); + + const saved = await store.updateFloorplan(floorplan.id, { + background_color: '#abcdef', + }); + + expect(saved.background_color).toBe('#abcdef'); + expect((await store.getFloorplan(floorplan.id)).background_color).toBe( + '#abcdef', + ); + expect(store.server_save_error()).toContain('too large'); + + await store.updateFloorplan(floorplan.id, { background_color: '#fff' }); + expect(store.server_save_error()).toBe(''); + }); + it('imports a level SVG into an existing local floorplan', async () => { const floorplan = await store.addFloorplan( 'project-1', @@ -190,9 +250,11 @@ describe('StoreService', () => { const imported = await store.importLevelSvg( floorplan.id, '', + '/maps/level-1.svg', ); expect(imported).toMatchObject({ + source_map_id: '/maps/level-1.svg', canvas_width: 800, canvas_height: 600, source_image_path: 'blob:level-svg', diff --git a/apps/map-studio/src/tests/svg-export.spec.ts b/apps/map-studio/src/tests/svg-export.spec.ts index ba3b4c330df..df66aa2a76b 100644 --- a/apps/map-studio/src/tests/svg-export.spec.ts +++ b/apps/map-studio/src/tests/svg-export.spec.ts @@ -94,6 +94,20 @@ describe('preflightChecks', () => { }); describe('buildSvg', () => { + const parse = (svg: string) => + new DOMParser().parseFromString(svg, 'image/svg+xml'); + + const wall = (partial: Partial = {}) => + object({ + id: 'w', + svg_id: 'wall-001', + object_type: 'decorative', + layer: 'walls', + label: '', + geometry: { type: 'path', d: 'M0 0 L20 0 L20 10 Z' }, + ...partial, + }); + it('emits a well-formed document at the canvas size', () => { const svg = buildSvg([object({})], 1200, 800); @@ -101,8 +115,7 @@ describe('buildSvg', () => { expect(svg).toContain('viewBox="0 0 1200 800"'); expect(svg.trimEnd().endsWith('')).toBe(true); // It must parse as XML - const doc = new DOMParser().parseFromString(svg, 'image/svg+xml'); - expect(doc.querySelector('parsererror')).toBeNull(); + expect(parse(svg).querySelector('parsererror')).toBeNull(); }); it('groups objects by type and keeps their svg ids', () => { @@ -119,7 +132,7 @@ describe('buildSvg', () => { 100, 100, ); - const doc = new DOMParser().parseFromString(svg, 'image/svg+xml'); + const doc = parse(svg); expect(doc.querySelector('#rooms')).not.toBeNull(); expect(doc.querySelector('#desks')).not.toBeNull(); @@ -138,13 +151,13 @@ describe('buildSvg', () => { it('escapes labels so markup stays valid', () => { const svg = buildSvg([object({ label: 'R&D ' })], 100, 100); - const doc = new DOMParser().parseFromString(svg, 'image/svg+xml'); + const doc = parse(svg); expect(doc.querySelector('parsererror')).toBeNull(); expect(doc.querySelector('text')?.textContent).toBe('R&D '); }); - it('renders each geometry type', () => { + it('renders each geometry type as the element carrying its id', () => { const svg = buildSvg( [ object({ id: 'a', svg_id: 'room-001' }), @@ -167,18 +180,128 @@ describe('buildSvg', () => { object_type: 'amenity', geometry: { type: 'circle', x: 5, y: 5, r: 3 }, }), + wall(), + ], + 100, + 100, + ); + const doc = parse(svg); + + expect(doc.querySelector('parsererror')).toBeNull(); + expect(doc.getElementById('room-001')?.localName).toBe('rect'); + expect(doc.getElementById('zone-001')?.localName).toBe('polygon'); + expect(doc.getElementById('amenity-001')?.localName).toBe('circle'); + expect(doc.getElementById('wall-001')?.localName).toBe('path'); + }); + + it('gives ids to the type layer groups and to no other group', () => { + const svg = buildSvg( + [ + object({ id: 'a', svg_id: 'room-001' }), + object({ + id: 'b', + svg_id: 'desk-001', + object_type: 'desk', + layer: 'desks', + }), + wall(), + ], + 100, + 100, + ); + const doc = parse(svg); + const groups = Array.from(doc.querySelectorAll('g[id]')); + + expect(groups.map((g) => g.id).sort()).toEqual([ + 'decoratives', + 'desks', + 'rooms', + ]); + expect( + groups.every((g) => g.parentElement === doc.documentElement), + ).toBe(true); + expect( + doc.getElementById('room-001')?.parentElement?.hasAttribute('id'), + ).toBe(false); + }); + + it('exports path objects with their path data', () => { + const d = 'M0 0 L20 0 L20 10 Z'; + const svg = buildSvg( + [wall({ geometry: { type: 'path', d } })], + 100, + 100, + ); + const doc = parse(svg); + const path = doc.getElementById('wall-001'); + + expect(doc.querySelector('parsererror')).toBeNull(); + expect(path?.getAttribute('d')).toBe(d); + expect(path?.getAttribute('data-type')).toBe('decorative'); + expect(doc.querySelector('text')).toBeNull(); + }); + + it('labels a path at the centre of its bounding box', () => { + const svg = buildSvg( + [ + object({ + svg_id: 'room-002', + label: 'Boardroom', + geometry: { + type: 'path', + d: 'M10 20 H110 V70 H10 Z', + x: 10, + y: 20, + width: 100, + height: 50, + }, + }), + ], + 200, + 200, + ); + const text = parse(svg).querySelector('text'); + + expect(text?.textContent).toBe('Boardroom'); + expect(text?.getAttribute('x')).toBe('60'); + expect(text?.getAttribute('y')).toBe('45'); + }); + + it('leaves a path without a bounding box unlabelled', () => { + const svg = buildSvg( + [ + object({ + svg_id: 'room-002', + label: 'Boardroom', + geometry: { type: 'path', d: 'M0 0 H10 V10 Z' }, + }), ], 100, 100, ); - const doc = new DOMParser().parseFromString(svg, 'image/svg+xml'); + const doc = parse(svg); - expect(doc.querySelector('#room-001 rect')).not.toBeNull(); - expect(doc.querySelector('#zone-001 polygon')).not.toBeNull(); - expect(doc.querySelector('#amenity-001 circle')).not.toBeNull(); + expect(doc.getElementById('room-002')).not.toBeNull(); + expect(doc.querySelector('text')).toBeNull(); }); - it('carries rotation through as a transform', () => { + it('skips a path object with no path data', () => { + const svg = buildSvg([wall({ geometry: { type: 'path' } })], 100, 100); + expect(svg).not.toContain('wall-001'); + }); + + it('keeps the label text beside a labelled rect', () => { + const svg = buildSvg([object({ label: 'Room 1' })], 100, 100); + const rect = parse(svg).getElementById('room-001'); + const text = rect?.parentElement?.querySelector('text'); + + expect(rect?.localName).toBe('rect'); + expect(text?.textContent).toBe('Room 1'); + expect(text?.getAttribute('x')).toBe('50'); + expect(text?.getAttribute('y')).toBe('25'); + }); + + it('carries rotation through as a transform on the wrapper', () => { const svg = buildSvg( [ object({ @@ -195,6 +318,11 @@ describe('buildSvg', () => { 100, 100, ); + const rect = parse(svg).getElementById('room-001'); + expect(svg).toContain('transform="rotate(45 50 25)"'); + expect(rect?.parentElement?.getAttribute('transform')).toBe( + 'rotate(45 50 25)', + ); }); }); diff --git a/apps/map-studio/src/tests/svg-import.spec.ts b/apps/map-studio/src/tests/svg-import.spec.ts index 8f98be5f42f..f4548b5d462 100644 --- a/apps/map-studio/src/tests/svg-import.spec.ts +++ b/apps/map-studio/src/tests/svg-import.spec.ts @@ -4,6 +4,35 @@ const svg = (body: string, attrs = 'viewBox="0 0 800 600"') => `${body}`; describe('analyzeSvg', () => { + it('uses viewBox coordinates for the canvas when display dimensions differ', () => { + const result = analyzeSvg( + svg( + '', + 'width="300" height="100" viewBox="10 20 800 600"', + ), + ); + expect(result.width).toBe(800); + expect(result.height).toBe(600); + expect(result.sourceSvg).toContain('width="300"'); + expect(result.sourceSvg).toContain('viewBox="10 20 800 600"'); + }); + + it('keeps definitions out of the object list and preserves furniture grouping', () => { + const result = analyzeSvg( + svg( + '', + ), + ); + expect(result.objects).toHaveLength(2); + expect( + result.objects.every( + (object) => + object.layer === 'background' && + object.groupId === 'desk-pieces', + ), + ).toBe(true); + expect(result.sourceSvg).toContain('id="icon"'); + }); it('takes dimensions from viewBox when width/height are absent', () => { const result = analyzeSvg( svg(''), @@ -135,16 +164,17 @@ describe('analyzeSvg', () => { expect(labels['room-3']).toBeNull(); }); - it('skips shapes with no id and reports how many', () => { + it('assigns anonymous shapes IDs and retains them in the source', () => { const result = analyzeSvg( svg( '', ), ); - expect(result.objects).toHaveLength(1); + expect(result.objects).toHaveLength(2); + expect(result.sourceSvg).toContain('id="imported-svg-1"'); expect( - result.issues.some((i) => i.message.includes('no id attribute')), + result.issues.some((i) => i.message.includes('assigned IDs')), ).toBe(true); }); diff --git a/bun.lock b/bun.lock index 5de2863cec7..801aecf3857 100644 --- a/bun.lock +++ b/bun.lock @@ -75,6 +75,7 @@ "date-fns": "^4.4.0", "date-fns-tz": "^3.2.0", "deepmerge": "^4.3.1", + "dompurify": "^3.4.16", "dotenv": "16.4.5", "eslint": "9.39.4", "eslint-plugin-playwright": "^1.6.2", @@ -1280,6 +1281,8 @@ "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="], @@ -1740,6 +1743,8 @@ "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + "dompurify": ["dompurify@3.4.16", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ=="], + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], "dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], diff --git a/docs/settings/concierge.md b/docs/settings/concierge.md index 53162f72838..402235ab119 100644 --- a/docs/settings/concierge.md +++ b/docs/settings/concierge.md @@ -59,6 +59,7 @@ Each locale entry requires an `id` and `name`: | `admin_group` | string | `"concierge_admins"` | Name of the user group that grants access to admin sections of the app, such as the management group of the sidebar. Members of `placeos_admin` and `placeos_support` are also admin in the sidebar and the parking pages. The signage playlist media list allows `placeos_admin` but not `placeos_support`. When no settings source sets it, the app uses `admin`. | | `has_uploads` | boolean | `true` | Whether file upload support (S3/cloud storage) is available in the app. | | `bulk_actions` | boolean | `false` | Let staff select several rows and apply one action to all of them. Desk, parking and locker bookings, parking requests and asset requests get approve and reject actions. Catering orders get a status action. | +| `map_studio_url` | string | – | Where the `Open in Map Studio` links in Map Management go. Defaults to `/map-studio/`. | ### Feature values @@ -75,6 +76,7 @@ Each locale entry requires an `id` and `name`: | `visitors` | Visitor bookings | Yes | | `visitor-rules` | Visitor rules | No | | `zones` | Zone management | Yes | +| `maps` | Map management | Yes | | `points` | Points management | Yes | | `emergency-contacts` | Emergency contacts | No | | `signage` | Signage management | Yes | diff --git a/libs/common/src/index.ts b/libs/common/src/index.ts index 179ff22192d..ca0c3d5121c 100644 --- a/libs/common/src/index.ts +++ b/libs/common/src/index.ts @@ -24,6 +24,7 @@ export * from './lib/settings.service'; export * from './lib/shorten.fn'; export * from './lib/teams-host'; export * from './lib/timezone-helpers'; +export * from './lib/token-handoff'; export * from './lib/uploads.service'; export * from './lib/user-group-sync'; export * from './lib/user-idle-time.service'; diff --git a/libs/common/src/lib/token-handoff.spec.ts b/libs/common/src/lib/token-handoff.spec.ts new file mode 100644 index 00000000000..b4b627dc08b --- /dev/null +++ b/libs/common/src/lib/token-handoff.spec.ts @@ -0,0 +1,161 @@ +vi.mock('@placeos/ts-client', { spy: true }); + +import * as ts_client from '@placeos/ts-client'; + +import { requestTokenFromOpener, shareTokenWith } from './token-handoff'; + +/** Unsigned JWT with the given `exp` claim in seconds */ +function jwt(exp: number) { + const payload = btoa(JSON.stringify({ exp })).replace(/=+$/, ''); + return `header.${payload}.signature`; +} + +/** A real window to stand in for another tab, so it can be a message source. */ +function otherWindow(): Window { + const frame = document.createElement('iframe'); + document.body.appendChild(frame); + return frame.contentWindow as Window; +} + +function message(source: Window, origin: string, data: unknown) { + window.dispatchEvent(new MessageEvent('message', { source, origin, data })); +} + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('shareTokenWith', () => { + const studio_origin = 'http://localhost:4201'; + let stop: () => void; + + beforeEach(() => vi.clearAllMocks()); + + afterEach(() => { + stop?.(); + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('answers a token request from the window it opened', async () => { + const token = jwt(Math.floor(Date.now() / 1000) + 3600); + vi.mocked(ts_client.authorise).mockResolvedValue(token); + const studio = otherWindow(); + const post = vi + .spyOn(studio, 'postMessage') + .mockImplementation(() => undefined); + stop = shareTokenWith(studio, studio_origin); + message(studio, studio_origin, { type: 'placeos:token-request' }); + await flush(); + expect(post).toHaveBeenCalledWith( + { + type: 'placeos:token', + token, + expires_at: expect.any(Number), + }, + studio_origin, + ); + }); + + it('ignores requests from other windows and other origins', async () => { + vi.mocked(ts_client.authorise).mockResolvedValue( + jwt(Math.floor(Date.now() / 1000) + 3600), + ); + const studio = otherWindow(); + const stranger = otherWindow(); + const post = vi + .spyOn(studio, 'postMessage') + .mockImplementation(() => undefined); + stop = shareTokenWith(studio, studio_origin); + message(stranger, studio_origin, { type: 'placeos:token-request' }); + message(studio, 'http://evil.test', { type: 'placeos:token-request' }); + await flush(); + expect(post).not.toHaveBeenCalled(); + expect(ts_client.authorise).not.toHaveBeenCalled(); + }); + + it('refreshes a token inside the expiry margin before handing it over', async () => { + const stale = jwt(Math.floor(Date.now() / 1000) + 60); + const fresh = jwt(Math.floor(Date.now() / 1000) + 7200); + vi.mocked(ts_client.authorise) + .mockResolvedValueOnce(stale) + .mockResolvedValueOnce(fresh); + vi.mocked(ts_client.invalidateToken).mockImplementation( + () => undefined, + ); + const studio = otherWindow(); + const post = vi + .spyOn(studio, 'postMessage') + .mockImplementation(() => undefined); + stop = shareTokenWith(studio, studio_origin); + message(studio, studio_origin, { type: 'placeos:token-request' }); + await flush(); + expect(ts_client.invalidateToken).toHaveBeenCalledTimes(1); + expect(post).toHaveBeenCalledWith( + expect.objectContaining({ token: fresh }), + studio_origin, + ); + }); +}); + +describe('requestTokenFromOpener', () => { + afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(window, 'opener', { + value: null, + configurable: true, + }); + document.body.innerHTML = ''; + }); + + function openedBy(opener: Window | null) { + Object.defineProperty(window, 'opener', { + value: opener, + configurable: true, + }); + } + + it('resolves the token the opener sends back', async () => { + const opener = otherWindow(); + const post = vi + .spyOn(opener, 'postMessage') + .mockImplementation(() => undefined); + openedBy(opener); + const request = requestTokenFromOpener(1000); + expect(post).toHaveBeenCalledWith( + { type: 'placeos:token-request' }, + '*', + ); + message(opener, `http://${location.hostname}:4200`, { + type: 'placeos:token', + token: 'abc', + expires_at: 123, + }); + await expect(request).resolves.toEqual({ + token: 'abc', + expires_at: 123, + }); + }); + + it('ignores replies from another host or another window', async () => { + vi.useFakeTimers(); + const opener = otherWindow(); + vi.spyOn(opener, 'postMessage').mockImplementation(() => undefined); + openedBy(opener); + const request = requestTokenFromOpener(1000); + message(opener, 'http://evil.test', { + type: 'placeos:token', + token: 'abc', + }); + message(otherWindow(), `http://${location.hostname}`, { + type: 'placeos:token', + token: 'abc', + }); + vi.advanceTimersByTime(1000); + await expect(request).resolves.toBeNull(); + vi.useRealTimers(); + }); + + it('resolves null when nothing opened this window', async () => { + openedBy(null); + await expect(requestTokenFromOpener(10)).resolves.toBeNull(); + }); +}); diff --git a/libs/common/src/lib/token-handoff.ts b/libs/common/src/lib/token-handoff.ts new file mode 100644 index 00000000000..55a8b89a90f --- /dev/null +++ b/libs/common/src/lib/token-handoff.ts @@ -0,0 +1,99 @@ +import { authorise, invalidateToken, token } from '@placeos/ts-client'; +import { tokenExpiry } from './teams-host'; + +const TOKEN_REQUEST = 'placeos:token-request'; +const TOKEN_REPLY = 'placeos:token'; +const CLOSED_CHECK_MS = 5 * 1000; + +/** A PlaceOS access token passed from one app window to another. */ +export interface HandedToken { + token: string; + /** Epoch milliseconds; undefined when the token is not a JWT. */ + expires_at?: number; +} + +/** + * Answers token requests from a window this app opened, for as long as that + * window stays open. Only messages from `target` at `origin` are answered. + * @returns a function that stops answering + */ +export function shareTokenWith(target: Window, origin: string): () => void { + const answer = async (event: MessageEvent) => { + if (event.source !== target || event.origin !== origin) return; + if (event.data?.type !== TOKEN_REQUEST) return; + const current = await freshToken(); + if (!current) return; + const reply: HandedToken & { type: string } = { + type: TOKEN_REPLY, + token: current, + expires_at: tokenExpiry(current), + }; + target.postMessage(reply, origin); + }; + const stop = () => { + window.removeEventListener('message', answer); + clearInterval(watch); + }; + const watch = setInterval(() => { + if (target.closed) stop(); + }, CLOSED_CHECK_MS); + window.addEventListener('message', answer); + return stop; +} + +/** + * Asks the window that opened this one for its PlaceOS token. Replies are + * accepted only from the opener on this page's host, any port. Resolves null + * when there is no opener or no reply within `timeout_ms`; never rejects. + */ +export function requestTokenFromOpener( + timeout_ms = 3000, +): Promise { + const opener = window.opener as Window | null; + if (!opener || opener === window) return Promise.resolve(null); + return new Promise((resolve) => { + const finish = (handed: HandedToken | null) => { + clearTimeout(timer); + window.removeEventListener('message', reply); + resolve(handed); + }; + const reply = (event: MessageEvent) => { + if (event.source !== opener) return; + if (!sameHost(event.origin)) return; + const data = event.data; + if (data?.type !== TOKEN_REPLY) return; + if (typeof data.token !== 'string' || !data.token) return; + finish({ + token: data.token, + expires_at: + typeof data.expires_at === 'number' + ? data.expires_at + : undefined, + }); + }; + const timer = setTimeout(() => finish(null), timeout_ms); + window.addEventListener('message', reply); + try { + opener.postMessage({ type: TOKEN_REQUEST }, '*'); + } catch { + finish(null); + } + }); +} + +/** This app's token, refreshed first when it is inside the expiry margin. */ +async function freshToken(): Promise { + const current = await authorise().catch(() => token()); + const expires_at = current ? tokenExpiry(current) : undefined; + if (!expires_at || expires_at > Date.now()) return current; + invalidateToken(); + return authorise().catch(() => ''); +} + +function sameHost(origin: string): boolean { + try { + return new URL(origin).hostname === location.hostname; + } catch { + return false; + } +} diff --git a/libs/map-editor/README.md b/libs/map-editor/README.md new file mode 100644 index 00000000000..4e8d2a4a9e9 --- /dev/null +++ b/libs/map-editor/README.md @@ -0,0 +1,10 @@ +# map-editor + +Document core for the PlaceOS map editor: a strictly parsed SVG map with session +anchors, an element index, a closed vocabulary of DOM operations with an applier +and a validator, snapshot history and a browser draft store. Framework-free so +it unit-tests in jsdom; Angular hosts wrap it. + +## Running unit tests + +Run `nx test map-editor` to execute the unit tests. diff --git a/libs/map-editor/project.json b/libs/map-editor/project.json new file mode 100644 index 00000000000..9bce10810ab --- /dev/null +++ b/libs/map-editor/project.json @@ -0,0 +1,34 @@ +{ + "name": "map-editor", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/map-editor/src", + "prefix": "placeos", + "tags": [], + "targets": { + "lint": {}, + "test": { + "executor": "@angular/build:unit-test", + "outputs": ["{workspaceRoot}/coverage/libs/map-editor"], + "options": { + "tsConfig": "libs/map-editor/tsconfig.spec.json", + "setupFiles": ["libs/map-editor/src/test-setup.ts"], + "isolate": true, + "watch": false, + "buildTarget": "map-editor:test-build:development" + } + }, + "test-build": { + "executor": "@angular/build:application", + "options": { + "outputPath": "dist/libs/map-editor", + "browser": "libs/map-editor/src/index.ts", + "tsConfig": "libs/map-editor/tsconfig.spec.json", + "polyfills": [] + }, + "configurations": { + "development": {} + } + } + } +} diff --git a/libs/map-editor/src/index.ts b/libs/map-editor/src/index.ts new file mode 100644 index 00000000000..e4630f560a8 --- /dev/null +++ b/libs/map-editor/src/index.ts @@ -0,0 +1,22 @@ +export * from './lib/ai'; +export * from './lib/apply'; +export * from './lib/canvas'; +export * from './lib/constants'; +export * from './lib/draft-store'; +export * from './lib/geometry'; +export * from './lib/history'; +export * from './lib/ids'; +export * from './lib/map-document'; +export * from './lib/map-index'; +export * from './lib/matrix'; +export * from './lib/ops'; +export * from './lib/ops-schema'; +export * from './lib/path-data'; +export * from './lib/protected-ids'; +export * from './lib/render-check'; +export * from './lib/sanitise'; +export * from './lib/session'; +export * from './lib/signature'; +export * from './lib/style-resolution'; +export * from './lib/validate'; +export * from './lib/value-syntax'; diff --git a/libs/map-editor/src/lib/ai/ai-panel.component.ts b/libs/map-editor/src/lib/ai/ai-panel.component.ts new file mode 100644 index 00000000000..384e7219179 --- /dev/null +++ b/libs/map-editor/src/lib/ai/ai-panel.component.ts @@ -0,0 +1,2297 @@ +import { NgTemplateOutlet } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + ElementRef, + input, + OnDestroy, + OnInit, + signal, + untracked, + viewChild, +} from '@angular/core'; +import { MapAiTransport } from './api'; +import { OpCategory } from './describe'; +import { PANEL_ICONS } from './panel-icons'; +import { previewSummary } from './preview'; +import { Proposal, ProposalGroup, ProposalRow } from './proposal'; +import { MapEditorSessionLike } from './session-contract'; +import { MapAiPhase, MapAiState, modelKey, ThreadTurn } from './state'; +import { + DEFAULT_AI_STRINGS, + format, + MapEditorAiStringsOverride, +} from './strings'; +import { MapAiThread, UploadPort } from './thread'; + +/** Markup over this size is not drawn as a thumbnail. */ +const THUMBNAIL_MAX_BYTES = 1_500_000; +/** After this long the running card shows the elapsed time. */ +const LONG_RUNNING_S = 10; + +interface Thumbnails { + before: string | null; + after: string | null; +} + +function objectUrl(markup: string | null | undefined): string | null { + if (!markup || markup.length > THUMBNAIL_MAX_BYTES) return null; + if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') + return null; + try { + return URL.createObjectURL( + new Blob([markup], { type: 'image/svg+xml' }), + ); + } catch { + return null; + } +} + +function revokeUrl(url: string | null): void { + if (url && typeof URL.revokeObjectURL === 'function') + URL.revokeObjectURL(url); +} + +const IDLE: MapAiPhase = { kind: 'idle' }; + +/** + * The AI tab of the map editor: capabilities at the top, the thread of + * requests and proposals, the composer at the bottom. A proposal that + * validates is previewed on the map at once; the card offers Keep and Undo. + */ +/** The rows of one change category, in proposal order, with a group when the section is tickable as a whole. */ +export interface PanelSection { + key: OpCategory; + label: string; + rows: ProposalRow[]; + group: ProposalGroup | null; +} + +const SECTION_ORDER: readonly OpCategory[] = [ + 'added', + 'changed', + 'removed', + 'question', + 'none', +]; + +/** "5", "5 and 6", "5, 6 and 7". */ +function listOf(numbers: readonly number[]): string { + if (numbers.length < 2) return numbers.join(''); + return `${numbers.slice(0, -1).join(', ')} and ${numbers.at(-1)}`; +} + +/** "claude-opus-5-5" reads "Claude Opus 5.5" and "gpt-5.4-mini" reads "GPT-5.4 mini"; other ids are shown as they are. */ +export function readableModel(model: string): string { + const claude = /^claude-([a-z]+)-(\d+)-(\d+)$/.exec(model); + if (claude) { + const family = claude[1]; + return `Claude ${family[0].toUpperCase()}${family.slice(1)} ${claude[2]}.${claude[3]}`; + } + const gpt = /^gpt-([\d.]+)(?:-(.+))?$/.exec(model); + if (gpt) return `GPT-${gpt[1]}${gpt[2] ? ` ${gpt[2]}` : ''}`; + return model; +} + +@Component({ + selector: 'map-editor-ai-panel', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (thread(); as t) { + @if (!hidden()) { +
+ @switch (phase().kind) { + @case ('idle') { +

{{ t.strings.checking }}

+ } + @case ('checking') { +

+ + {{ t.strings.checking }} +

+ } + @case ('unavailable') { +

+ {{ unavailableText() }} +

+ } + @default { +
+

{{ t.strings.title }}

+ @if (model_options().length > 1) { + + } @else if (model_note()) { +

+ {{ model_note() }} +

+ } + @if (quota_note()) { +

+ {{ quota_note() }} +

+ } + @if (t.quota_block(); as block) { +

+ {{ block }} +

+ } +
+ @if ( + !t.turns().length && phase().kind !== 'running' + ) { + + } +
    + @for (turn of t.turns(); track turn.id) { + @switch (turn.kind) { + @case ('person') { +
  1. +

    + {{ turn.instruction }} +

    + @if (personMeta(turn)) { +

    + {{ personMeta(turn) }} +

    + } +
  2. + } + @case ('status') { +
  3. + {{ + turn.text + }} + @if ( + turn.error?.retryable && + isLastTurn(turn) + ) { + + } +
  4. + } + @case ('proposal') { +
  5. +
    +

    + {{ + cardTitle( + turn.proposal + ) + }} +

    + @if (outcomeText(turn)) { + + {{ + outcomeText( + turn + ) + }} + + } +
    +
    + @if ( + turn.proposal.summary + ) { +

    + {{ + turn.proposal + .summary + }} +

    + } + @if ( + turn.proposal.questions + .length + ) { +
    + @for ( + question of turn + .proposal + .questions; + track question + ) { +

    + {{ + question + }} +

    + } +

    + {{ + t.strings + .questions_hint + }} +

    +
    + } + @if ( + verdictText( + turn.proposal + ); + as text + ) { +

    + @if ( + turn.proposal + .invalid_count || + turn.proposal + .blocking + ) { + + } + {{ + text + }} +

    + } + @if ( + isActive(turn) && + t.selection_error() + ) { + + } + @for ( + issue of turn.proposal + .batch_issues; + track $index + ) { +

    + {{ issue.message }} +

    + } + @if ( + isActive(turn) && + (thumbnails().before || + thumbnails().after) + ) { +
    + @if ( + thumbnails() + .before; + as before + ) { +
    + +
    + {{ + t + .strings + .before + }} +
    +
    + } + @if ( + thumbnails() + .after; + as after + ) { +
    + +
    + {{ + t + .strings + .after + }} +
    +
    + } +
    + } +
    + @if ( + sectionsOf(turn.proposal); + as sections + ) { + @for ( + section of sections; + track section.key + ) { +
    +
    + @if ( + section.group + ) { + + } + + {{ + section + .rows + .length + }} + @if ( + section.rows + .length >= + 4 + ) { + + } +
    + @if ( + isGroupOpen( + turn.id + + ':' + + section.key + ) + ) { +
      + @for ( + row of section.rows; + track row.id + ) { + + } +
    + } +
    + } + } + @if ( + isActive(turn) && + showsDecision() && + hasReasons() + ) { +
    + {{ + t.strings + .reason_prompt + }} + @for ( + chip of reason_chips(); + track chip.key + ) { + + } +
    + } +
  6. + } + } + } + @if (phase().kind === 'running') { +
  7. + + {{ + running_text() + }} + +
  8. + } +
+ @if (activeTurn(); as active) { + @if (showsDecision()) { +
+ @if ( + phase().kind === 'applied' || + revalidating() + ) { + + } + +
+ } + } + @if ( + t.turns().length || phase().kind === 'running' + ) { + + } + } + } +
+ } +
+ {{ t.announcement() }} +
+ } + + +
+
+ + @if (t.turns().length) { + + } +
+
+ + +
+
+
+ + +
  • + @if (row.actionable) { + + } @else { + + } + + {{ categoryWord(row) }} + + {{ row.kind_word }} + + + @if (row.actionable && row.locked) { + + + {{ words().locked_note }} + + } + @if (row.depends_on.length) { + + + {{ needsText(row) }} + + } + @if (row.dependents.length) { + {{ neededByText(row) }} + } + @if (row.note) { + {{ row.note }} + } + @for (issue of rowIssues(row); track $index) { + + {{ issue.message }} + + } + + +
  • +
    + `, + styles: [ + ` + :host { + --ai-fg: var(--base-content, #111); + --ai-fg-2: color-mix(in srgb, var(--ai-fg) 62%, transparent); + --ai-line: color-mix(in srgb, var(--ai-fg) 11%, transparent); + --ai-line-strong: color-mix( + in srgb, + var(--ai-fg) 20%, + transparent + ); + --ai-bg: var(--base-100, #fff); + --ai-wash: color-mix(in srgb, var(--ai-fg) 3.5%, var(--ai-bg)); + --ai-card: color-mix(in srgb, var(--ai-bg) 80%, #fff); + --ai-accent: var(--secondary, #1f2155); + --ai-tint: color-mix(in srgb, var(--ai-fg) 5%, transparent); + --ai-radius: 0.75rem; + + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + color: var(--ai-fg); + font-size: 0.875rem; + line-height: 1.25rem; + } + + :host-context(.theme-dark) { + --ai-accent: color-mix( + in srgb, + var(--secondary, #1f2155) 40%, + #fff + ); + } + + .panel { + display: flex; + flex: 1; + flex-direction: column; + height: 100%; + min-height: 0; + } + + .sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; + } + + p { + margin: 0; + } + + .meta { + color: var(--ai-fg-2); + font-size: 0.75rem; + line-height: 1rem; + } + + .error { + color: var(--error, #dc2626); + } + + .warning { + color: color-mix(in srgb, var(--warn, #d97706) 70%, #000); + } + + .grow { + flex: 1; + min-width: 0; + } + + .note { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 1rem; + color: var(--ai-fg-2); + font-size: 0.75rem; + } + + /* Header */ + + .head { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem; + border-bottom: 1px solid var(--ai-line); + } + + .head h2 { + margin: 0; + font-size: 1rem; + font-weight: 600; + line-height: 1.5rem; + } + + /* Concierge's global label rule sets width, margin and weight. */ + label.model, + label.label { + width: auto; + min-width: 0; + margin: 0; + } + + label.model { + display: block; + font-size: inherit; + font-weight: 400; + } + + .model select { + box-sizing: border-box; + width: 100%; + height: 2.25rem; + padding: 0 2rem 0 0.75rem; + border: 1px solid var(--ai-line-strong); + border-radius: 0.5rem; + background-color: var(--ai-card); + background-image: + linear-gradient(45deg, transparent 50%, currentColor 50%), + linear-gradient(135deg, currentColor 50%, transparent 50%); + background-position: + right 0.95rem center, + right 0.65rem center; + background-size: 0.3rem 0.3rem; + background-repeat: no-repeat; + color: inherit; + font: inherit; + font-size: 0.8125rem; + appearance: none; + cursor: pointer; + } + + .model select:hover { + border-color: color-mix(in srgb, var(--ai-fg) 35%, transparent); + } + + .head .meta + .meta { + margin-top: -0.25rem; + } + + /* Thread */ + + /* Hosts such as Concierge turn text selection off for every element. */ + .thread, + .thread * { + user-select: text; + } + + .thread button, + .thread button * { + user-select: none; + } + + .thread { + display: flex; + flex: 1; + flex-direction: column; + gap: 1rem; + min-height: 0; + margin: 0; + padding: 1rem; + overflow: auto; + list-style: none; + background: var(--ai-wash); + } + + .thread.empty { + flex: 0; + padding: 0; + } + + .person { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.25rem; + margin-left: 2.5rem; + } + + .bubble { + padding: 0.75rem 1rem; + border-radius: var(--ai-radius) var(--ai-radius) 0.25rem + var(--ai-radius); + background: color-mix( + in srgb, + var(--ai-accent) 9%, + var(--ai-card) + ); + white-space: pre-line; + } + + .status { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.75rem 1rem; + border: 1px solid var(--ai-line); + border-radius: var(--ai-radius); + background: var(--ai-card); + color: var(--ai-fg-2); + } + + .status.failed { + border-color: color-mix( + in srgb, + var(--error, #dc2626) 35%, + transparent + ); + background: color-mix( + in srgb, + var(--error, #dc2626) 6%, + var(--ai-card) + ); + color: var(--ai-fg); + } + + /* Proposal card */ + + .card { + border: 1px solid var(--ai-line); + border-radius: var(--ai-radius); + background: var(--ai-card); + box-shadow: 0 1px 2px + color-mix(in srgb, var(--ai-fg) 5%, transparent); + } + + .card.inactive { + opacity: 0.65; + } + + .card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 1rem 1rem 0; + } + + .card-head h3 { + margin: 0; + font-size: 0.875rem; + font-weight: 600; + } + + .badge { + display: inline-flex; + flex: none; + align-items: center; + gap: 0.375rem; + padding: 0.125rem 0.5rem; + border-radius: 999px; + background: var(--ai-tint); + color: var(--ai-fg-2); + font-size: 0.75rem; + font-weight: 500; + line-height: 1rem; + } + + .badge::before { + content: ''; + width: 0.375rem; + height: 0.375rem; + border-radius: 999px; + background: currentColor; + } + + .badge[data-outcome='previewing'] { + background: color-mix( + in srgb, + var(--info, #2563eb) 12%, + transparent + ); + color: color-mix(in srgb, var(--info, #2563eb) 75%, #000); + } + + .badge[data-outcome='kept'], + .badge[data-outcome='partial'], + .badge[data-outcome='refined'], + .badge[data-outcome='abandoned'] { + background: color-mix( + in srgb, + var(--success, #16a34a) 12%, + transparent + ); + color: color-mix(in srgb, var(--success, #16a34a) 70%, #000); + } + + .badge[data-outcome='questions'] { + background: color-mix( + in srgb, + var(--warn, #d97706) 16%, + transparent + ); + color: color-mix(in srgb, var(--warn, #d97706) 45%, #000); + } + + .badge[data-outcome='failed'], + .badge[data-outcome='not_applied'] { + background: color-mix( + in srgb, + var(--error, #dc2626) 10%, + transparent + ); + color: var(--error, #dc2626); + } + + :host-context(.theme-dark) .badge { + color: var(--ai-fg); + } + + .card-body { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0.5rem 1rem 1rem; + } + + .summary { + line-height: 1.375rem; + } + + .callout { + display: flex; + flex-direction: column; + gap: 0.375rem; + padding: 0.75rem; + border-left: 3px solid var(--ai-line-strong); + border-radius: 0.25rem 0.5rem 0.5rem 0.25rem; + background: var(--ai-tint); + color: var(--ai-fg); + font-size: 0.8125rem; + } + + p.callout { + flex-direction: row; + align-items: flex-start; + gap: 0.5rem; + } + + .callout.ask { + border-left-color: var(--warn, #d97706); + background: color-mix( + in srgb, + var(--warn, #d97706) 9%, + transparent + ); + } + + .callout.error { + border-left-color: var(--error, #dc2626); + background: color-mix( + in srgb, + var(--error, #dc2626) 7%, + transparent + ); + color: var(--ai-fg); + } + + .callout svg { + width: 1rem; + height: 1rem; + flex: none; + margin-top: 0.125rem; + fill: var(--error, #dc2626); + } + + .question { + font-weight: 500; + } + + .head .callout { + font-size: 0.75rem; + line-height: 1rem; + } + + .thumbs { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; + } + + .thumbs figure { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin: 0; + } + + .thumbs img { + display: block; + width: 100%; + aspect-ratio: 16 / 9; + object-fit: contain; + border: 1px solid var(--ai-line); + border-radius: 0.5rem; + background: var(--ai-wash); + } + + .thumbs figcaption { + color: var(--ai-fg-2); + font-size: 0.75rem; + line-height: 1rem; + } + + /* Change list */ + + .section { + padding: 0.5rem; + border-top: 1px solid var(--ai-line); + } + + .section-head { + display: flex; + align-items: center; + gap: 0.625rem; + min-height: 2rem; + padding: 0 0.5rem; + } + + .section-label { + font-size: 0.75rem; + font-weight: 600; + line-height: 1rem; + } + + .count { + min-width: 1.25rem; + padding: 0 0.375rem; + border-radius: 999px; + background: var(--ai-tint); + color: var(--ai-fg-2); + font-size: 0.6875rem; + font-weight: 600; + line-height: 1.125rem; + text-align: center; + } + + .section-head .link { + margin-left: auto; + } + + input[type='checkbox'] { + flex: none; + width: 1rem; + height: 1rem; + margin: 0; + accent-color: var(--ai-accent); + cursor: pointer; + } + + input[type='checkbox']:disabled { + cursor: default; + } + + .rows { + display: flex; + flex-direction: column; + gap: 0.125rem; + margin: 0; + padding: 0; + list-style: none; + } + + .row { + display: flex; + align-items: flex-start; + gap: 0.625rem; + padding: 0.5rem; + border-radius: 0.5rem; + font-size: 0.8125rem; + } + + .row:hover, + .row.hot { + background: var(--ai-tint); + } + + .row input[type='checkbox'] { + margin-top: 0.125rem; + } + + .row .spacer { + flex: none; + width: 1rem; + } + + .num { + box-sizing: border-box; + flex: none; + min-width: 1.25rem; + height: 1.25rem; + padding: 0 0.25rem; + border: 1.5px solid var(--info, #2563eb); + border-radius: 999px; + font-size: 0.6875rem; + font-weight: 600; + line-height: calc(1.25rem - 3px); + text-align: center; + } + + .row[data-category='added'] .num { + border-color: var(--success, #16a34a); + } + + .row[data-category='removed'] .num { + border-color: var(--error, #dc2626); + border-style: dashed; + } + + .row[data-category='question'] .num { + border-color: var(--warn, #d97706); + } + + .row.hot .num { + background: var(--primary, #2563eb); + border-color: var(--primary, #2563eb); + border-style: solid; + color: var(--primary-content, #fff); + } + + .row .body { + display: flex; + flex: 1; + flex-direction: column; + gap: 0.125rem; + min-width: 0; + } + + .row-text { + padding: 0; + border: none; + background: none; + color: inherit; + font: inherit; + text-align: left; + overflow-wrap: anywhere; + cursor: pointer; + } + + .row-text:hover { + text-decoration: underline; + text-underline-offset: 2px; + } + + .notes { + display: flex; + flex-direction: column; + gap: 0.125rem; + color: var(--ai-fg-2); + font-size: 0.75rem; + line-height: 1rem; + } + + .notes:empty { + display: none; + } + + .notes > span { + display: inline-flex; + align-items: flex-start; + gap: 0.25rem; + } + + .notes svg { + width: 0.875rem; + height: 0.875rem; + flex: none; + margin-top: 0.0625rem; + fill: currentColor; + } + + /* Feedback */ + + .reasons { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + padding: 1rem; + border-top: 1px solid var(--ai-line); + } + + .reasons .meta { + width: 100%; + } + + /* Controls */ + + .link { + padding: 0.125rem 0.25rem; + border: none; + border-radius: 0.25rem; + background: none; + color: var(--ai-fg-2); + font: inherit; + font-size: 0.75rem; + font-weight: 500; + line-height: 1rem; + cursor: pointer; + } + + .link:hover { + color: var(--ai-fg); + text-decoration: underline; + text-underline-offset: 2px; + } + + .link:disabled { + opacity: 0.4; + cursor: default; + text-decoration: none; + } + + .chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + height: 1.625rem; + padding: 0 0.625rem; + border: 1px solid transparent; + border-radius: 999px; + background: var(--ai-tint); + color: inherit; + font: inherit; + font-size: 0.75rem; + font-weight: 500; + white-space: nowrap; + } + + button.chip { + border-color: var(--ai-line-strong); + background: transparent; + cursor: pointer; + } + + button.chip:hover { + background: var(--ai-tint); + } + + button.chip.on { + border-color: var(--ai-accent); + background: color-mix( + in srgb, + var(--ai-accent) 10%, + transparent + ); + } + + .chip .clear { + display: inline-flex; + margin-right: -0.25rem; + padding: 0; + border: none; + border-radius: 999px; + background: none; + color: inherit; + cursor: pointer; + } + + .chip svg { + width: 0.875rem; + height: 0.875rem; + fill: currentColor; + } + + .decide { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; + padding: 0.75rem 1rem; + border-top: 1px solid var(--ai-line); + background: var(--ai-bg); + } + + .decide .wide { + grid-column: span 2; + } + + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + height: 2.5rem; + padding: 0 1rem; + border: 1px solid var(--ai-accent); + border-radius: 0.5rem; + background: var(--ai-accent); + color: var(--secondary-content, #fff); + font: inherit; + font-weight: 600; + white-space: nowrap; + cursor: pointer; + } + + .btn:hover { + background: color-mix(in srgb, var(--ai-accent) 88%, #000); + } + + .btn.quiet { + border-color: var(--ai-line-strong); + background: var(--ai-card); + color: var(--ai-fg); + } + + .btn.quiet:hover { + background: var(--ai-tint); + } + + .btn.small { + height: 2rem; + padding: 0 0.75rem; + font-size: 0.8125rem; + } + + .btn:disabled { + opacity: 0.35; + pointer-events: none; + } + + .btn svg { + width: 1.125rem; + height: 1.125rem; + fill: currentColor; + } + + .btn.small svg { + width: 1rem; + height: 1rem; + } + + /* Composer */ + + .composer { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem 1rem 1rem; + border-top: 1px solid var(--ai-line); + background: var(--ai-bg); + } + + .composer.first { + padding-top: 1rem; + border-top: none; + } + + .composer-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + min-height: 1.25rem; + } + + .label { + font-size: 0.75rem; + font-weight: 600; + line-height: 1rem; + } + + .field { + border: 1px solid var(--ai-line-strong); + border-radius: 0.625rem; + background: var(--ai-card); + } + + .field:focus-within { + border-color: var(--ai-accent); + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--ai-accent) 15%, transparent); + } + + .field.invalid { + border-color: var(--error, #dc2626); + } + + .field textarea { + display: block; + box-sizing: border-box; + width: 100%; + height: 4.5rem; + padding: 0.625rem 0.75rem 0.25rem; + border: none; + outline: none; + background: transparent; + color: inherit; + font: inherit; + line-height: 1.375rem; + resize: none; + } + + .field textarea::placeholder { + color: color-mix(in srgb, var(--ai-fg) 45%, transparent); + } + + .field textarea.tall { + height: 8rem; + } + + .field textarea:disabled { + opacity: 0.6; + } + + .footer { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.375rem 0.375rem 0.75rem; + } + + .footer .meta.error { + color: var(--error, #dc2626); + } + + .row-text:focus-visible, + .link:focus-visible, + .btn:focus-visible, + .chip .clear:focus-visible, + button.chip:focus-visible, + .model select:focus-visible, + input[type='checkbox']:focus-visible { + outline: 2px solid var(--primary, #2563eb); + outline-offset: 2px; + } + + .spinner { + flex: none; + width: 1rem; + height: 1rem; + border-radius: 9999px; + border: 2px solid currentColor; + border-right-color: transparent; + animation: map-editor-ai-spin 0.8s linear infinite; + } + + @keyframes map-editor-ai-spin { + to { + transform: rotate(360deg); + } + } + + @media (prefers-reduced-motion: no-preference) { + .row, + .num, + .btn, + .link, + .field, + .model select, + button.chip { + transition: + background-color 120ms, + border-color 120ms, + box-shadow 120ms, + color 120ms; + } + } + + @media (prefers-reduced-motion: reduce) { + .spinner { + animation: none; + } + } + `, + ], + imports: [NgTemplateOutlet], +}) +export class MapEditorAiPanelComponent implements OnInit, OnDestroy { + public readonly session = input.required(); + public readonly zone_id = input.required(); + /** Stores the snapshot and answers with its upload id; without it no picture is sent. */ + public readonly upload = input(null); + public readonly strings = input(null); + public readonly transport = input(undefined); + + public readonly thread = signal(null); + public readonly draft = signal(''); + /** Sections the person collapsed; everything else is open. */ + public readonly closed_groups = signal>(new Set()); + public readonly icons = PANEL_ICONS; + private readonly _thread_list = + viewChild>('threadList'); + public readonly thumbnails = signal({ + before: null, + after: null, + }); + public readonly elapsed_s = signal(0); + + public readonly state = computed( + () => this.thread()?.state() ?? null, + ); + public readonly words = computed( + () => this.thread()?.strings ?? DEFAULT_AI_STRINGS, + ); + public readonly phase = computed( + () => this.state()?.phase ?? IDLE, + ); + /** The host hides the panel: the deployment has no map AI. */ + public readonly hidden = computed(() => { + const phase = this.phase(); + return phase.kind === 'unavailable' && phase.hidden; + }); + public readonly selection_count = computed( + () => this.session().selection().length, + ); + public readonly limits = computed( + () => this.state()?.capabilities?.limits ?? null, + ); + public readonly model_note = computed(() => { + const thread = this.thread(); + const capabilities = thread?.capabilities(); + const choice = thread?.choice(); + if (!thread || !capabilities?.enabled || !choice) return ''; + const provider = capabilities.providers.find( + (candidate) => candidate.id === choice.provider_id, + ); + return provider + ? format(thread.strings.model_note, { + model: readableModel(choice.model), + provider: provider.name, + }) + : ''; + }); + public readonly model_options = computed( + () => this.thread()?.model_options() ?? [], + ); + /** The model's readable name, with its provider when another option offers the same model. */ + public optionLabel(option: { + model: string; + provider_name: string; + }): string { + const shared = this.model_options().some( + (other) => other !== option && other.model === option.model, + ); + const name = readableModel(option.model); + return shared ? `${name} · ${option.provider_name}` : name; + } + + public readonly model_key = computed(() => { + const choice = this.thread()?.choice(); + return choice ? modelKey(choice) : ''; + }); + public readonly quota_note = computed(() => { + const thread = this.thread(); + const quota = thread?.capabilities()?.quota; + if (!thread || !quota) return ''; + const notes: string[] = []; + if (typeof quota.user_remaining_today === 'number') { + notes.push( + format(thread.strings.quota_note, { + count: quota.user_remaining_today, + }), + ); + } + if (typeof quota.domain_remaining_month === 'number') { + notes.push( + format(thread.strings.quota_domain_note, { + count: quota.domain_remaining_month, + }), + ); + } + return notes.join(' · '); + }); + public readonly chars_note = computed(() => { + const thread = this.thread(); + const max = this.limits()?.max_instruction_chars; + return thread && max + ? format(thread.strings.chars, { + used: this.draft().length, + max, + }) + : ''; + }); + public readonly too_long = computed(() => { + const max = this.limits()?.max_instruction_chars; + return !!max && this.draft().length > max; + }); + public readonly can_send = computed(() => { + const thread = this.thread(); + return ( + !!thread && + thread.can_send() && + this.draft().trim().length > 0 && + !this.too_long() + ); + }); + public readonly placeholder = computed(() => { + const thread = this.thread(); + if (!thread) return ''; + return thread.proposal()?.verdict === 'questions' + ? thread.strings.reply_placeholder + : thread.strings.composer_placeholder; + }); + public readonly cancelling = computed(() => { + const phase = this.phase(); + return phase.kind === 'running' && phase.cancelling; + }); + public readonly running_text = computed(() => { + const thread = this.thread(); + if (!thread || this.phase().kind !== 'running') return ''; + const seconds = this.elapsed_s(); + return seconds >= LONG_RUNNING_S + ? format(thread.strings.running_long, { seconds }) + : thread.strings.running; + }); + public readonly reason_chips = computed(() => { + const thread = this.thread(); + if (!thread) return []; + return Object.entries(thread.strings.reasons).map(([key, label]) => ({ + key, + label, + })); + }); + public readonly active_job_id = computed( + () => this.thread()?.proposal()?.job.id ?? null, + ); + public readonly selectionText = computed(() => { + const thread = this.thread(); + return thread + ? format(thread.strings.selection_chip, { + count: this.selection_count(), + }) + : ''; + }); + public readonly unavailableText = computed(() => { + const thread = this.thread(); + const phase = this.phase(); + if (!thread || phase.kind !== 'unavailable') return ''; + return format(thread.strings.unavailable, { reason: phase.reason }); + }); + /** True while a changed selection is checked before it is previewed again. */ + public readonly revalidating = computed(() => { + const phase = this.phase(); + return phase.kind === 'reviewing' && phase.validating; + }); + public readonly showsDecision = computed(() => { + const phase = this.phase(); + if (phase.kind === 'applied') return true; + return ( + phase.kind === 'reviewing' && + phase.proposal.rows.some((row) => row.actionable) + ); + }); + + constructor() { + effect(() => { + this.session().version(); + untracked(() => this.thread()?.mapChanged()); + }); + effect((onCleanup) => { + const proposal = this.thread()?.proposal() ?? null; + const thumbnails: Thumbnails = { + before: objectUrl(proposal?.before_markup), + after: + proposal?.verdict === 'blocked' + ? null + : objectUrl(proposal?.trial_markup), + }; + untracked(() => this.thumbnails.set(thumbnails)); + onCleanup(() => { + revokeUrl(thumbnails.before); + revokeUrl(thumbnails.after); + }); + }); + effect(() => { + const session = this.session(); + const thread = this.thread(); + const proposal = thread?.proposal() ?? null; + const phase = this.phase(); + const previewed = + phase.kind === 'applied' || + (phase.kind === 'reviewing' && !!phase.on_map); + const summary = + thread && proposal && previewed + ? previewSummary(proposal, thread.strings) + : null; + untracked(() => session.setPreview?.(summary)); + }); + effect(() => { + const id = this.active_job_id(); + const list = this._thread_list()?.nativeElement; + if (!id || !list) return; + setTimeout(() => { + const turn = list.querySelector( + `[proposal-card][data-job="${id}"]`, + ); + if (!turn) return; + const inset = + parseFloat(getComputedStyle(list).paddingTop) || 0; + list.scrollTop = turn.offsetTop - list.offsetTop - inset; + }); + }); + effect((onCleanup) => { + const phase = this.phase(); + if (phase.kind !== 'running') { + untracked(() => this.elapsed_s.set(0)); + return; + } + const started = phase.started_at; + const tick = () => + this.elapsed_s.set( + Math.max(0, Math.floor((Date.now() - started) / 1000)), + ); + untracked(tick); + const timer = setInterval(tick, 1000); + onCleanup(() => clearInterval(timer)); + }); + } + + public ngOnInit(): void { + const thread = new MapAiThread(this.session(), this.zone_id(), { + transport: this.transport(), + upload: this.upload(), + strings: this.strings(), + }); + this.thread.set(thread); + void thread.load(); + } + + public ngOnDestroy(): void { + this.thread()?.dispose(); + this.session().setPreview?.(null); + } + + public onDraft(event: Event): void { + const value = (event.target as HTMLTextAreaElement).value; + this.draft.set(value); + this.thread()?.setDraft(value); + } + + public onKeydown(event: KeyboardEvent): void { + if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { + event.preventDefault(); + void this.send(); + } + } + + public async send(event?: Event): Promise { + event?.preventDefault(); + const thread = this.thread(); + if (!thread || !this.can_send()) return; + const text = this.draft(); + this.draft.set(''); + await thread.submit(text); + } + + public retry(): void { + void this.thread()?.retry(); + } + + public cancel(): void { + void this.thread()?.cancel(); + } + + public keep(): void { + void this.thread()?.keep(); + } + + public undo(): void { + void this.thread()?.undo(); + } + + public newRequest(): void { + this.thread()?.reset(); + this.draft.set(''); + } + + public clearSelection(): void { + this.session().select([]); + } + + public hasReason(key: string): boolean { + return this.state()?.reasons.includes(key) ?? false; + } + + public isActive(turn: ThreadTurn): boolean { + return turn.kind === 'proposal' && turn.job_id === this.active_job_id(); + } + + public isLastTurn(turn: ThreadTurn): boolean { + const turns = this.thread()?.turns() ?? []; + return turns[turns.length - 1]?.id === turn.id; + } + + public onRow(row: ProposalRow, event: Event): void { + const checkbox = event.target as HTMLInputElement; + const checked = checkbox.checked; + checkbox.checked = row.checked; + void this.thread()?.toggle(row.index, checked); + } + + public onGroup(group: ProposalGroup, event: Event): void { + const checkbox = event.target as HTMLInputElement; + const checked = checkbox.checked; + checkbox.checked = group.checked === 'all'; + checkbox.indeterminate = group.checked === 'some'; + void this.thread()?.toggleGroup(group, checked); + } + + public isGroupOpen(key: string): boolean { + return !this.closed_groups().has(key); + } + + public toggleGroupOpen(key: string): void { + this.closed_groups.update((closed) => { + const next = new Set(closed); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + } + + /** The row's issues without repeats, and without one that only repeats its note. */ + public rowIssues(row: ProposalRow): ProposalRow['issues'] { + const seen = new Set(row.note ? [row.note] : []); + return row.issues.filter((issue) => { + if (seen.has(issue.message)) return false; + seen.add(issue.message); + return true; + }); + } + + /** + * The proposal's rows by category, in the order added, changed, removed, + * question, none. Questions already shown above the list are left out. + */ + public sectionsOf(proposal: Proposal): PanelSection[] { + const thread = this.thread(); + if (!thread) return []; + const sections: PanelSection[] = []; + for (const category of SECTION_ORDER) { + const rows = proposal.rows.filter( + (row) => row.category === category, + ); + if (!rows.length) continue; + if ( + category === 'question' && + rows.every((row) => proposal.questions.includes(row.text)) + ) + continue; + const tickable = rows.filter( + (row) => row.actionable && !row.locked, + ); + const ticked = tickable.filter((row) => row.checked).length; + const group: ProposalGroup | null = + tickable.length >= 3 + ? { + key: category, + label: thread.strings.categories[category], + category, + kind: rows[0].kind, + indices: rows.map((row) => row.index), + checked: + ticked === 0 + ? 'none' + : ticked === tickable.length + ? 'all' + : 'some', + locked: false, + } + : null; + sections.push({ + key: category, + label: thread.strings.categories[category], + rows, + group, + }); + } + return sections; + } + + public activeTurn(): ThreadTurn | null { + const id = this.active_job_id(); + if (!id) return null; + return ( + this.thread() + ?.turns() + .find( + (turn) => turn.kind === 'proposal' && turn.job_id === id, + ) ?? null + ); + } + + public hasReasons(): boolean { + return this.reason_chips().length > 0; + } + + /** The row's element is under the pointer, on the map or in the list. */ + public isHot(row: ProposalRow): boolean { + const hover = this.session().hover(); + return !!hover && row.anchors.includes(hover); + } + + public rowsOf(proposal: Proposal, group: ProposalGroup): ProposalRow[] { + return group.indices + .map((index) => proposal.rows[index]) + .filter((row): row is ProposalRow => !!row); + } + + public hoverRow(row: ProposalRow | null): void { + this.thread()?.hoverRow(row); + } + + public selectRow(row: ProposalRow): void { + this.thread()?.selectRow(row); + } + + public categoryWord(row: ProposalRow): string { + return this.thread()?.strings.categories[row.category] ?? row.category; + } + + public needsText(row: ProposalRow): string { + const thread = this.thread(); + if (!thread) return ''; + return row.depends_on + .map((index) => format(thread.strings.needs, { n: index + 1 })) + .join(', '); + } + + public neededByText(row: ProposalRow): string { + const thread = this.thread(); + if (!thread) return ''; + const numbers = row.dependents.map((index) => index + 1); + if (numbers.length === 1) + return format(thread.strings.needed_by_one, { n: numbers[0] }); + return format(thread.strings.needed_by_many, { + list: listOf(numbers), + }); + } + + public cardTitle(proposal: Proposal): string { + const thread = this.thread(); + if (!thread) return ''; + if (proposal.verdict === 'questions') + return thread.strings.questions_title; + return format(thread.strings.proposal_title, { + count: proposal.rows.filter((row) => row.actionable).length, + }); + } + + public verdictText(proposal: Proposal): string { + const thread = this.thread(); + if (!thread) return ''; + const states = thread.strings.states; + switch (proposal.verdict) { + case 'empty': + return format(states.empty, { + summary: proposal.summary || '-', + }); + case 'blocked': + return states.blocked; + case 'nothing_accepted': + return states.nothing_accepted; + case 'questions': + return ''; + default: + return proposal.invalid_count + ? format(states.partially_invalid, { + count: proposal.rows.filter((row) => row.actionable) + .length, + invalid: proposal.invalid_count, + }) + : ''; + } + } + + public outcomeText(turn: ThreadTurn): string { + const thread = this.thread(); + if (!thread || turn.kind !== 'proposal') return ''; + const outcomes = thread.strings.outcomes; + switch (turn.outcome) { + case 'partial': { + const total = turn.proposal.rows.filter( + (row) => row.actionable, + ).length; + return format(outcomes.partial, { + accepted: turn.proposal.accepted.length, + total, + }); + } + case 'refined': + case 'abandoned': + return outcomes.kept; + default: + return outcomes[turn.outcome] ?? ''; + } + } + + public personMeta(turn: ThreadTurn): string { + const thread = this.thread(); + if (!thread || turn.kind !== 'person') return ''; + const parts: string[] = []; + if (turn.selected) + parts.push( + format(thread.strings.selection_chip, { count: turn.selected }), + ); + if (turn.snapshot) parts.push(thread.strings.snapshot_attached); + return parts.join(' · '); + } + + public chooseModel(event: Event): void { + const thread = this.thread(); + if (!thread) return; + const key = (event.target as HTMLSelectElement).value; + const option = thread + .model_options() + .find((candidate) => candidate.key === key); + thread.chooseModel( + option + ? { provider_id: option.provider_id, model: option.model } + : null, + ); + } +} diff --git a/libs/map-editor/src/lib/ai/annotations.ts b/libs/map-editor/src/lib/ai/annotations.ts new file mode 100644 index 00000000000..990e5fec2fa --- /dev/null +++ b/libs/map-editor/src/lib/ai/annotations.ts @@ -0,0 +1,185 @@ +import { SVG_NS } from '../constants'; +import { MapIndex } from '../map-index'; +import { MapAIAnnotation } from './types'; + +export type AnnotationTool = 'select' | 'ellipse' | 'box' | 'path' | 'arrow'; + +export const ANNOTATION_COLOURS = [ + { name: 'red', value: '#dc2626' }, + { name: 'blue', value: '#2563eb' }, + { name: 'green', value: '#15803d' }, + { name: 'orange', value: '#c2410c' }, + { name: 'purple', value: '#9333ea' }, +] as const; + +export function annotationColour(mark: MapAIAnnotation): string { + return ( + ANNOTATION_COLOURS.find((colour) => colour.name === mark.colour) + ?.value || ANNOTATION_COLOURS[0].value + ); +} + +/** Every point a mark covers in viewBox units: its path, its box corners and an arrow's tip. */ +export function annotationPoints(mark: MapAIAnnotation): [number, number][] { + const points: [number, number][] = [...(mark.points || [])]; + if (mark.x !== undefined && mark.y !== undefined) { + points.push([mark.x, mark.y]); + // An arrow's width and height are absolute deltas, so only boxes and ellipses have this corner + if (mark.kind === 'box' || mark.kind === 'ellipse') + points.push([ + mark.x + (mark.width || 0), + mark.y + (mark.height || 0), + ]); + } + if (mark.x2 !== undefined && mark.y2 !== undefined) + points.push([mark.x2, mark.y2]); + return points.filter(([x, y]) => Number.isFinite(x) && Number.isFinite(y)); +} + +/** The same marks on the canvas overlay and the uploaded snapshot, in viewBox units. */ +export function drawAnnotations( + overlay: SVGElement, + marks: readonly MapAIAnnotation[], + units_per_px: number, +): void { + const owner = overlay.ownerDocument; + const stroke = Math.max(0.1, units_per_px * 3); + for (const mark of marks) { + const colour = annotationColour(mark); + const group = owner.createElementNS(SVG_NS, 'g'); + group.setAttribute('data-annotation', String(mark.n)); + const x = mark.x ?? mark.points?.[0]?.[0] ?? 0; + const y = mark.y ?? mark.points?.[0]?.[1] ?? 0; + let shape: SVGElement; + if (mark.kind === 'ellipse') { + shape = owner.createElementNS(SVG_NS, 'ellipse'); + shape.setAttribute('cx', String(x + (mark.width || 0) / 2)); + shape.setAttribute('cy', String(y + (mark.height || 0) / 2)); + shape.setAttribute('rx', String((mark.width || 0) / 2)); + shape.setAttribute('ry', String((mark.height || 0) / 2)); + } else if (mark.kind === 'box') { + shape = owner.createElementNS(SVG_NS, 'rect'); + shape.setAttribute('x', String(x)); + shape.setAttribute('y', String(y)); + shape.setAttribute('width', String(mark.width || 0)); + shape.setAttribute('height', String(mark.height || 0)); + } else { + shape = owner.createElementNS(SVG_NS, 'path'); + let d = ''; + if (mark.kind === 'arrow') { + const end_x = mark.x2 ?? x; + const end_y = mark.y2 ?? y; + const angle = Math.atan2(end_y - y, end_x - x); + const head = units_per_px * 12; + const wing = (side: number) => + `${end_x - head * Math.cos(angle + (side * Math.PI) / 6)} ${end_y - head * Math.sin(angle + (side * Math.PI) / 6)}`; + d = `M${x} ${y}L${end_x} ${end_y}M${wing(-1)}L${end_x} ${end_y}L${wing(1)}`; + } else { + d = (mark.points || []) + .map(([px, py], index) => `${index ? 'L' : 'M'}${px} ${py}`) + .join(''); + } + shape.setAttribute('d', d); + } + shape.setAttribute( + 'style', + `fill:none;stroke:${colour};stroke-width:${stroke};stroke-linecap:round;stroke-linejoin:round;pointer-events:none`, + ); + group.appendChild(shape); + if (!mark.n) { + overlay.appendChild(group); + continue; + } + const badge = owner.createElementNS(SVG_NS, 'text'); + badge.setAttribute('x', String(x)); + badge.setAttribute('y', String(y - units_per_px * 5)); + badge.setAttribute( + 'style', + `font:700 ${units_per_px * 13}px sans-serif;fill:${colour};stroke:#fff;stroke-width:${units_per_px * 3};paint-order:stroke;pointer-events:none`, + ); + badge.textContent = String(mark.n); + group.appendChild(badge); + overlay.appendChild(group); + } +} + +function insidePolygon( + point: [number, number], + points: readonly [number, number][], +): boolean { + let inside = false; + for (let i = 0, j = points.length - 1; i < points.length; j = i++) { + const [xi, yi] = points[i]; + const [xj, yj] = points[j]; + if ( + yi > point[1] !== yj > point[1] && + point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi + ) + inside = !inside; + } + return inside; +} + +/** Candidates inside a circled area, or under an arrow tip, smallest first. */ +export function annotationCandidates( + index: MapIndex, + mark: MapAIAnnotation, +): string[] { + return index.entries + .filter((entry) => { + if ( + !entry.bbox || + !entry.visible || + entry.kind === 'root' || + entry.kind === 'non-rendered' || + (entry.tag === 'g' && + entry.kind !== 'bookable' && + entry.kind !== 'zone') + ) + return false; + const [x, y, width, height] = entry.bbox; + if (mark.kind === 'arrow') { + const px = mark.x2 ?? 0; + const py = mark.y2 ?? 0; + return ( + px >= x && px <= x + width && py >= y && py <= y + height + ); + } + if ( + !entry.id && + width * height > (mark.width || 0) * (mark.height || 0) * 4 + ) + return false; + const centre: [number, number] = [x + width / 2, y + height / 2]; + if (mark.kind === 'box') + return ( + centre[0] >= (mark.x || 0) && + centre[0] <= (mark.x || 0) + (mark.width || 0) && + centre[1] >= (mark.y || 0) && + centre[1] <= (mark.y || 0) + (mark.height || 0) + ); + if (mark.kind === 'ellipse') { + const rx = (mark.width || 0) / 2; + const ry = (mark.height || 0) / 2; + return ( + rx > 0 && + ry > 0 && + ((centre[0] - (mark.x || 0) - rx) / rx) ** 2 + + ((centre[1] - (mark.y || 0) - ry) / ry) ** 2 <= + 1 + ); + } + return ( + mark.kind === 'path' && insidePolygon(centre, mark.points || []) + ); + }) + .sort((a, b) => { + const named = Number(!!b.id) - Number(!!a.id); + if (named) return named; + const aa = a.bbox!; + const bb = b.bbox!; + return aa[2] * aa[3] - bb[2] * bb[3]; + }) + .slice(0, 32) + .map((entry) => entry.anchor); +} diff --git a/libs/map-editor/src/lib/ai/api.ts b/libs/map-editor/src/lib/ai/api.ts new file mode 100644 index 00000000000..8495b418b04 --- /dev/null +++ b/libs/map-editor/src/lib/ai/api.ts @@ -0,0 +1,315 @@ +import { apiEndpoint, get, post } from '@placeos/ts-client'; + +import { + MapAICapabilities, + MapAIEditRequest, + MapAIJob, + MapAIJobQueryOptions, + MapAIJobShowOptions, + MapAIJobState, + MapAIResolveRequest, + MapAIUsageOptions, + MapAIUsageRow, +} from './types'; + +/** How requests reach the server; specs substitute a fake. */ +export interface MapAiTransport { + get(url: string): Promise; + post(url: string, body: unknown): Promise; +} + +/** The ts-client HTTP functions, which attach the session's bearer token. */ +export const PLACEOS_TRANSPORT: MapAiTransport = { + get: (url) => get(url), + post: (url, body) => post(url, body), +}; + +const PATH = () => `${apiEndpoint()}/maps/ai`; + +/** `?a=1&b=2` from the defined entries, empty when there are none. */ +export function toQuery(params: object): string { + const pairs = Object.entries(params as Record) + .filter(([, value]) => value !== undefined && value !== null) + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`, + ); + return pairs.length ? `?${pairs.join('&')}` : ''; +} + +/** What this domain can do, and what the caller has left of their quota. */ +export function mapAICapabilities( + transport: MapAiTransport = PLACEOS_TRANSPORT, +): Promise { + return transport.get( + `${PATH()}/capabilities`, + ) as Promise; +} + +/** + * Ask for operations that carry out an instruction on a level's map. Answers + * as soon as the job is accepted; `watchJob` follows it to its end. + */ +export function submitMapAIEdit( + request: MapAIEditRequest, + transport: MapAiTransport = PLACEOS_TRANSPORT, +): Promise { + return transport.post(`${PATH()}/edit`, request) as Promise; +} + +/** + * One job, optionally holding the request open until it changes past `since` + * or `wait` seconds pass. + */ +export function showMapAIJob( + id: string, + query_params: MapAIJobShowOptions = {}, + transport: MapAiTransport = PLACEOS_TRANSPORT, +): Promise { + return transport.get( + `${PATH()}/jobs/${encodeURIComponent(id)}${toQuery(query_params)}`, + ) as Promise; +} + +/** Recent map edit jobs, the caller's own unless `mine` is false. */ +export function queryMapAIJobs( + query_params: MapAIJobQueryOptions = {}, + transport: MapAiTransport = PLACEOS_TRANSPORT, +): Promise { + return transport.get(`${PATH()}/jobs${toQuery(query_params)}`) as Promise< + MapAIJob[] + >; +} + +/** Ask a running job to stop. A call already with the vendor runs to completion. */ +export function cancelMapAIJob( + id: string, + transport: MapAiTransport = PLACEOS_TRANSPORT, +): Promise { + return transport.post( + `${PATH()}/jobs/${encodeURIComponent(id)}/cancel`, + {}, + ) as Promise; +} + +/** Record what the person did with the answer. */ +export function resolveMapAIJob( + id: string, + request: MapAIResolveRequest, + transport: MapAiTransport = PLACEOS_TRANSPORT, +): Promise { + return transport.post( + `${PATH()}/jobs/${encodeURIComponent(id)}/resolve`, + request, + ) as Promise; +} + +/** Map edit spend per provider and model; support users only. */ +export function mapAIUsage( + query_params: MapAIUsageOptions = {}, + transport: MapAiTransport = PLACEOS_TRANSPORT, +): Promise { + return transport.get(`${PATH()}/usage${toQuery(query_params)}`) as Promise< + MapAIUsageRow[] + >; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** The HTTP status carried by a ts-client error or its wrapped response. */ +export function errorStatus(error: unknown): number | undefined { + if (!isRecord(error)) return undefined; + const status = error['status']; + if (typeof status === 'number') return status; + const nested = error['error']; + if (!isRecord(nested)) return undefined; + const nested_status = nested['status']; + return typeof nested_status === 'number' ? nested_status : undefined; +} + +/** The server's message for a failed request, when it sent one. */ +export function errorMessage(error: unknown): string { + if (!isRecord(error)) return typeof error === 'string' ? error : ''; + const response = error['response']; + if (isRecord(response)) { + const message = response['message'] ?? response['error']; + if (typeof message === 'string' && message) return message; + } + if (typeof response === 'string' && response) return response; + const message = error['message']; + return typeof message === 'string' ? message : ''; +} + +export const FINAL_JOB_STATES: ReadonlySet = + new Set(['done', 'failed', 'cancelled']); + +export function isFinalJob(job: Pick | null | undefined) { + return !!job && FINAL_JOB_STATES.has(job.state); +} + +/** Seconds one long poll holds the connection; the server caps it at 25. */ +export const POLL_WAIT = 25; +/** Consecutive failed polls before a job is given up on. */ +export const POLL_RETRIES = 10; +/** A job still not final after this long has stopped responding. */ +export const MAX_JOB_WAIT_MS = 30 * 60 * 1000; +const BACKOFF_BASE_MS = 1000; +const BACKOFF_CAP_MS = 30_000; + +/** 1 s, 2 s, 4 s ... capped at 30 s. */ +export function backoffDelay(failures: number): number { + return Math.min( + BACKOFF_CAP_MS, + BACKOFF_BASE_MS * 2 ** Math.max(0, failures - 1), + ); +} + +/** A poll failure worth retrying: no status (network), 408, 429 or 5xx. */ +export function isTransientError(error: unknown): boolean { + const status = errorStatus(error); + return ( + status === undefined || + status === 408 || + status === 429 || + status >= 500 + ); +} + +/** Seconds a long poll may run past its `wait` before it counts as a failed poll. */ +const POLL_GRACE_SECONDS = 10; + +/** Rejects with a status-less error, which counts as transient, if `promise` has not settled in `ms`. */ +function withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('the job poll timed out')), + ms, + ); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + +function isJobBody(job: unknown): job is MapAIJob { + const value = job as Partial | null; + return ( + !!value && + typeof value.id === 'string' && + typeof value.state === 'string' && + typeof value.version === 'number' + ); +} + +export class WatchAbortedError extends Error { + constructor() { + super('the job watch was aborted'); + this.name = 'WatchAbortedError'; + } +} + +export class WatchTimeoutError extends Error { + constructor(public readonly job_id: string) { + super(`job ${job_id} did not finish in time`); + this.name = 'WatchTimeoutError'; + } +} + +export class WatchGaveUpError extends Error { + constructor( + public readonly job_id: string, + public readonly failures: number, + public readonly last_error: unknown, + ) { + super(`gave up on job ${job_id} after ${failures} failed polls`); + this.name = 'WatchGaveUpError'; + } +} + +export type Sleep = (ms: number, signal?: AbortSignal) => Promise; + +/** Resolves after `ms`, or at once when the signal aborts. */ +export function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + const timer = setTimeout(done, ms); + function done() { + clearTimeout(timer); + signal?.removeEventListener('abort', done); + resolve(); + } + signal?.addEventListener('abort', done, { once: true }); + }); +} + +export interface WatchJobOptions { + transport?: MapAiTransport; + /** Aborting stops the watch after the poll in flight returns. */ + signal?: AbortSignal; + /** The version already seen; the first poll returns once the job moves past it. */ + since?: number; + wait?: number; + max_failures?: number; + max_wait_ms?: number; + /** Every job state received, final one included. */ + on_update?: (job: MapAIJob) => void; + sleep?: Sleep; + now?: () => number; +} + +/** + * Long-polls a job until it is final. Transient failures retry with backoff; + * a 4xx other than 408 and 429 rejects at once. Rejects with + * WatchAbortedError, WatchTimeoutError or WatchGaveUpError otherwise. + */ +export async function watchJob( + id: string, + options: WatchJobOptions = {}, +): Promise { + const transport = options.transport ?? PLACEOS_TRANSPORT; + const wait = options.wait ?? POLL_WAIT; + const max_failures = options.max_failures ?? POLL_RETRIES; + const max_wait_ms = options.max_wait_ms ?? MAX_JOB_WAIT_MS; + const pause = options.sleep ?? sleep; + const now = options.now ?? Date.now; + const deadline = now() + max_wait_ms; + let since = options.since; + let failures = 0; + for (;;) { + if (options.signal?.aborted) throw new WatchAbortedError(); + if (now() > deadline) throw new WatchTimeoutError(id); + let job: MapAIJob; + try { + job = await withTimeout( + showMapAIJob(id, { wait, since }, transport), + (wait + POLL_GRACE_SECONDS) * 1000, + ); + if (!isJobBody(job)) throw new Error('the job poll had no job'); + } catch (error) { + if (options.signal?.aborted) throw new WatchAbortedError(); + if (!isTransientError(error)) throw error; + failures += 1; + if (failures > max_failures) + throw new WatchGaveUpError(id, failures, error); + await pause(backoffDelay(failures), options.signal); + continue; + } + if (options.signal?.aborted) throw new WatchAbortedError(); + failures = 0; + options.on_update?.(job); + if (isFinalJob(job)) return job; + since = job.version; + } +} diff --git a/libs/map-editor/src/lib/ai/context.ts b/libs/map-editor/src/lib/ai/context.ts new file mode 100644 index 00000000000..4447fd55ff0 --- /dev/null +++ b/libs/map-editor/src/lib/ai/context.ts @@ -0,0 +1,852 @@ +import { MapDocument, stripEditorAttributes, ViewBox } from '../map-document'; +import { MapIndex, MapIndexEntry } from '../map-index'; +import { formatDeclarations, StyleRule } from '../style-resolution'; +import { annotationPoints } from './annotations'; +import { snapshotSize, SnapshotSize } from './snapshot'; +import { + MAP_AI_CONTEXT_SCHEMA, + MAP_AI_MARKUP_SCHEMA, + MapAIAnnotation, + MapAIContext, + MapAIContextEntry, + MapAIViewBox, +} from './types'; + +/** The server's default `MAP_AI_MAX_CONTEXT_BYTES`. */ +export const DEFAULT_MAX_CONTEXT_BYTES = 512 * 1024; +/** The server cuts each subtree at this many bytes. */ +export const MAX_SUBTREE_BYTES = 8000; + +export interface ContextSource { + doc: MapDocument; + index(): MapIndex; +} + +export interface BuildContextOptions { + /** Anchors of the selected elements. */ + selection?: readonly string[]; + annotations?: readonly MapAIAnnotation[]; + /** The instruction; elements whose ids it names join the shortlist. */ + instruction?: string; + /** Anchors near the person's marks, from hit-testing. */ + shortlist?: readonly string[]; + max_bytes?: number; + max_subtree_bytes?: number; + /** Size of the snapshot being sent; computed from the viewBox when absent. */ + snapshot?: SnapshotSize | null; + long_side?: number; +} + +export interface ContextDrops { + subtrees: number; + anonymous: number; + named: number; +} + +export interface BuiltContext { + context: MapAIContext; + /** JSON size in bytes. */ + bytes: number; + dropped: ContextDrops; + /** Under the byte budget. */ + fits: boolean; +} + +const encoder = new TextEncoder(); + +function bytesOf(value: unknown): number { + return encoder.encode(JSON.stringify(value)).length; +} + +export function viewBoxTuple(view_box: ViewBox): MapAIViewBox { + return [view_box.x, view_box.y, view_box.width, view_box.height]; +} + +/** An element the model can name: it has an id, a label or a booking binding. */ +export function isNamedEntry(entry: MapIndexEntry): boolean { + return !!entry.id || !!entry.label || entry.protected; +} + +export function bboxArea(entry: MapIndexEntry): number { + return entry.bbox ? entry.bbox[2] * entry.bbox[3] : 0; +} + +/** + * The entries worth sending, named ones first in document order, then the + * anonymous ones largest first so the budget drops the small ones. + */ +export function orderEntries(index: MapIndex): MapIndexEntry[] { + const listed = index.entries.filter( + (entry) => entry.kind !== 'root' && entry.kind !== 'non-rendered', + ); + const named = listed.filter(isNamedEntry); + const anonymous = listed + .filter((entry) => !isNamedEntry(entry)) + .map((entry, order) => ({ entry, order, area: bboxArea(entry) })) + .sort((a, b) => b.area - a.area || a.order - b.order) + .map((item) => item.entry); + return [...named, ...anonymous]; +} + +/** Entries whose box can contain others: bookables and zones with a non-empty box, smallest first. */ +export function containerEntries(index: MapIndex): MapIndexEntry[] { + return index.entries + .filter( + (entry) => + (entry.kind === 'bookable' || entry.kind === 'zone') && + !!entry.bbox && + entry.bbox[2] > 0 && + entry.bbox[3] > 0, + ) + .sort((a, b) => bboxArea(a) - bboxArea(b)); +} + +const CONTAINMENT_TOLERANCE = 0.5; + +/** The smallest container whose box holds the entry's box, by anchor; entries outside every container are absent. */ +export function containerIndex(index: MapIndex): Map { + const containers = containerEntries(index); + const result = new Map(); + for (const entry of index.entries) { + if ( + !entry.bbox || + entry.kind === 'root' || + entry.kind === 'non-rendered' + ) + continue; + const [x, y, w, h] = entry.bbox; + for (const container of containers) { + if (container === entry) continue; + const [cx, cy, cw, ch] = container.bbox as [ + number, + number, + number, + number, + ]; + if (cw * ch <= w * h) continue; + if ( + x >= cx - CONTAINMENT_TOLERANCE && + y >= cy - CONTAINMENT_TOLERANCE && + x + w <= cx + cw + CONTAINMENT_TOLERANCE && + y + h <= cy + ch + CONTAINMENT_TOLERANCE + ) { + result.set(entry.anchor, container); + break; + } + } + } + return result; +} + +const GLYPH_MAX_HEIGHT_RATIO = 0.15; +const GLYPH_MAX_WIDTH_RATIO = 0.12; +const GLYPH_MIN_HEIGHT = 3; +const LABEL_MIN_GLYPHS = 3; +const ROW_GAP_RATIO = 1.5; +const ROW_OVERLAP_RATIO = 0.5; +const ROW_STACK_RATIO = 1.2; +// Indexed bounds are rounded to whole SVG units. +const PUNCTUATION_EDGE_TOLERANCE = 1; + +/** An anonymous path small enough to be one letter of an outlined label inside its container. */ +export function isGlyphCandidate( + entry: MapIndexEntry, + container: MapIndexEntry | undefined, +): boolean { + if (!container?.bbox || !entry.bbox) return false; + if (entry.id || entry.label || entry.protected) return false; + if (entry.tag !== 'path' && entry.tag !== 'polygon') return false; + if (entry.parent === container.anchor) return false; + if (!entry.fill || entry.fill === 'none') return false; + const [, , w, h] = entry.bbox; + const [, , cw, ch] = container.bbox; + return ( + h >= GLYPH_MIN_HEIGHT && + h <= ch * GLYPH_MAX_HEIGHT_RATIO && + w <= cw * GLYPH_MAX_WIDTH_RATIO + ); +} + +type Box = [number, number, number, number]; + +function unionBox(boxes: readonly Box[]): Box { + const x0 = Math.min(...boxes.map((b) => b[0])); + const y0 = Math.min(...boxes.map((b) => b[1])); + const x1 = Math.max(...boxes.map((b) => b[0] + b[2])); + const y1 = Math.max(...boxes.map((b) => b[1] + b[3])); + return [x0, y0, x1 - x0, y1 - y0]; +} + +function sameRow(last: Box, next: Box): boolean { + const overlap = + Math.min(last[1] + last[3], next[1] + next[3]) - + Math.max(last[1], next[1]); + const tallest = Math.max(last[3], next[3]); + const gap = next[0] - (last[0] + last[2]); + return ( + overlap >= Math.min(last[3], next[3]) * ROW_OVERLAP_RATIO && + gap <= tallest * ROW_GAP_RATIO && + gap >= -tallest * ROW_OVERLAP_RATIO + ); +} + +function stacked(upper: Box, lower: Box): boolean { + const gap = lower[1] - (upper[1] + upper[3]); + const horizontal = + Math.min(upper[0] + upper[2], lower[0] + lower[2]) - + Math.max(upper[0], lower[0]); + return ( + gap >= -Math.min(upper[3], lower[3]) * ROW_OVERLAP_RATIO && + gap <= Math.max(upper[3], lower[3]) * ROW_STACK_RATIO && + horizontal > 0 + ); +} + +export interface LabelCluster { + kind: 'glyphs' | 'text'; + in: string | null; + anchors: string[]; + bbox: Box; + fill: string | null; + text: string | null; +} + +interface GlyphRun { + anchors: string[]; + bbox: Box; +} + +/** + * Rows of adjacent glyphs, left to right, with rows that stack joined into one + * run. Rows shorter than LABEL_MIN_GLYPHS are dropped. + */ +function glyphRuns(members: readonly MapIndexEntry[]): GlyphRun[] { + const sorted = [...members].sort( + (a, b) => (a.bbox as Box)[0] - (b.bbox as Box)[0], + ); + const rows: MapIndexEntry[][] = []; + for (const member of sorted) { + const row = rows.find((candidate) => + sameRow( + candidate[candidate.length - 1].bbox as Box, + member.bbox as Box, + ), + ); + if (row) row.push(member); + else rows.push([member]); + } + const labels = rows + .filter((row) => row.length >= LABEL_MIN_GLYPHS) + .map((row) => ({ + rows: [row], + bbox: unionBox(row.map((entry) => entry.bbox as Box)), + })) + .sort((a, b) => a.bbox[1] - b.bbox[1]); + const merged: typeof labels = []; + for (const label of labels) { + const above = merged.find((candidate) => + stacked(candidate.bbox, label.bbox), + ); + if (above) { + above.rows.push(...label.rows); + above.bbox = unionBox([above.bbox, label.bbox]); + } else merged.push(label); + } + return merged.map((label) => ({ + anchors: label.rows.flat().map((entry) => entry.anchor), + bbox: label.bbox, + })); +} + +const LOOSE_GLYPH_MIN_RATIO = 0.6; +const LOOSE_GLYPH_MAX_RATIO = 1.6; +const LOOSE_GLYPH_WIDTH_RATIO = 2; + +function median(values: readonly number[]): number { + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2; +} + +/** + * An anonymous filled path sized like previously identified glyphs. Shapes + * lying on a sibling are the layers of a drawn object, not letters. + */ +export function isLooseGlyphCandidate( + entry: MapIndexEntry, + glyph_height: number, + siblings: readonly MapIndexEntry[], +): boolean { + if (!entry.bbox || entry.id || entry.label || entry.protected) return false; + if (entry.tag !== 'path' && entry.tag !== 'polygon') return false; + if (!entry.fill || entry.fill === 'none') return false; + const [, , w, h] = entry.bbox; + if ( + h < glyph_height * LOOSE_GLYPH_MIN_RATIO || + h > glyph_height * LOOSE_GLYPH_MAX_RATIO || + w > glyph_height * LOOSE_GLYPH_WIDTH_RATIO + ) + return false; + return !siblings.some( + (other) => + other !== entry && + other.parent === entry.parent && + sameDrawing(entry.bbox as Box, other.bbox), + ); +} + +/** A text element's words: its own text and each tspan as one line. */ +export function textWords(element: Element): string { + const lines = Array.from(element.childNodes).map((node) => + node.nodeType === Node.TEXT_NODE || + (node as Element).localName === 'tspan' + ? (node.textContent || '').trim() + : '', + ); + return lines.filter((line) => !!line).join('\n'); +} + +/** + * The labels on the map. Outlined labels are glyph candidates with one parent + * and one fill inside one container, clustered into runs; paths outside every + * container sized like those glyphs are clustered the same way. Text elements + * are labels as they stand, with their words. + */ +export function labelClusters( + index: MapIndex, + containers: Map, + words: (anchor: string) => string | null = () => null, +): LabelCluster[] { + const clusters: LabelCluster[] = []; + const groups = new Map< + string, + { container: MapIndexEntry; members: MapIndexEntry[] } + >(); + for (const entry of index.entries) { + const container = containers.get(entry.anchor); + if (!isGlyphCandidate(entry, container) || !container) continue; + const key = `${container.anchor}|${entry.parent}|${entry.fill}`; + const group = groups.get(key) || { container, members: [] }; + group.members.push(entry); + groups.set(key, group); + } + const glyph_heights: number[] = []; + for (const { container, members } of groups.values()) { + for (const run of glyphRuns(members)) { + clusters.push({ + kind: 'glyphs', + in: container.id || container.anchor, + anchors: run.anchors, + bbox: run.bbox, + fill: members[0].fill, + text: null, + }); + for (const anchor of run.anchors) { + const box = index.by_anchor.get(anchor)?.bbox; + if (box) glyph_heights.push(box[3]); + } + } + } + if (glyph_heights.length) { + const typical = median(glyph_heights); + const clustered = new Set(clusters.flatMap((label) => label.anchors)); + const label_styles = new Set( + index.entries + .filter((entry) => clustered.has(entry.anchor)) + .map((entry) => `${entry.parent}|${entry.fill}`), + ); + const remaining = index.entries.filter( + (entry) => + !clustered.has(entry.anchor) && + (!containers.has(entry.anchor) || + label_styles.has(`${entry.parent}|${entry.fill}`)) && + entry.kind !== 'root' && + entry.kind !== 'non-rendered' && + entry.kind !== 'bookable' && + entry.kind !== 'zone', + ); + const loose = new Map(); + for (const entry of remaining) { + if (!isLooseGlyphCandidate(entry, typical, index.entries)) continue; + const container = containers.get(entry.anchor); + const key = `${container?.anchor || ''}|${entry.parent}|${entry.fill}`; + const members = loose.get(key) || []; + members.push(entry); + loose.set(key, members); + } + for (const members of loose.values()) { + for (const run of glyphRuns(members)) { + const container = containers.get(run.anchors[0]); + clusters.push({ + kind: 'glyphs', + in: container ? container.id || container.anchor : null, + anchors: run.anchors, + bbox: run.bbox, + fill: members[0].fill, + text: null, + }); + } + } + } + const assigned_glyphs = new Set(clusters.flatMap((label) => label.anchors)); + for (const cluster of clusters) { + const parent = index.by_anchor.get(cluster.anchors[0])?.parent; + const [x, y, width, height] = cluster.bbox; + for (const entry of index.entries) { + if ( + assigned_glyphs.has(entry.anchor) || + entry.id || + entry.label || + entry.protected || + !entry.visible || + !entry.bbox || + entry.parent !== parent || + entry.fill !== cluster.fill || + (entry.tag !== 'path' && entry.tag !== 'polygon') + ) + continue; + const [px, py, pw, ph] = entry.bbox; + if ( + pw <= 0 || + ph <= 0 || + pw >= GLYPH_MIN_HEIGHT || + ph >= GLYPH_MIN_HEIGHT || + px < x - PUNCTUATION_EDGE_TOLERANCE || + py < y - PUNCTUATION_EDGE_TOLERANCE || + px + pw > x + width + PUNCTUATION_EDGE_TOLERANCE || + py + ph > y + height + PUNCTUATION_EDGE_TOLERANCE + ) + continue; + cluster.anchors.push(entry.anchor); + assigned_glyphs.add(entry.anchor); + } + } + for (const entry of index.entries) { + if (entry.tag !== 'text' || !entry.bbox) continue; + const text = words(entry.anchor) ?? entry.text ?? entry.label; + if (!text) continue; + const container = containers.get(entry.anchor); + clusters.push({ + kind: 'text', + in: container ? container.id || container.anchor : null, + anchors: [entry.anchor], + bbox: entry.bbox as Box, + fill: entry.fill, + text, + }); + } + return clusters; +} + +const PIECE_MIN_OVERLAP = 0.4; + +function boxOverlap(a: Box, b: Box): number { + const w = Math.min(a[0] + a[2], b[0] + b[2]) - Math.max(a[0], b[0]); + const h = Math.min(a[1] + a[3], b[1] + b[3]) - Math.max(a[1], b[1]); + if (w <= 0 || h <= 0) return 0; + const smaller = Math.min(a[2] * a[3], b[2] * b[3]); + return smaller > 0 ? (w * h) / smaller : 0; +} + +/** Whether two boxes lie on each other closely enough to be layers of one drawn object. */ +export function sameDrawing(a: Box | null, b: Box | null): boolean { + if (!a || !b || a[2] <= 0 || a[3] <= 0 || b[2] <= 0 || b[3] <= 0) + return false; + return boxOverlap(a, b) >= PIECE_MIN_OVERLAP; +} + +/** + * Anonymous rendered siblings whose boxes lie on each other are the layers + * of one drawn object; each such group gets one piece id, p1, p2, ... in + * document order. Glyphs and containers are left out. + */ +export function pieceIndex( + index: MapIndex, + glyphs: ReadonlySet, +): Map { + const by_parent = new Map(); + for (const entry of index.entries) { + if ( + entry.id || + entry.label || + entry.protected || + !entry.bbox || + entry.kind === 'root' || + entry.kind === 'non-rendered' || + entry.kind === 'bookable' || + entry.kind === 'zone' || + entry.kind === 'layer-group' || + glyphs.has(entry.anchor) + ) + continue; + const key = entry.parent || ''; + const list = by_parent.get(key) || []; + list.push(entry); + by_parent.set(key, list); + } + const group_of = new Map(); + const members = new Map(); + let next_group = 0; + for (const siblings of by_parent.values()) { + for (let i = 0; i < siblings.length; i++) { + const a = siblings[i]; + for (let j = i + 1; j < siblings.length; j++) { + const b = siblings[j]; + if (!sameDrawing(a.bbox as Box, b.bbox as Box)) continue; + const ga = group_of.get(a.anchor); + const gb = group_of.get(b.anchor); + if (ga === undefined && gb === undefined) { + const id = next_group++; + group_of.set(a.anchor, id); + group_of.set(b.anchor, id); + members.set(id, [a.anchor, b.anchor]); + } else if (ga !== undefined && gb === undefined) { + group_of.set(b.anchor, ga); + members.get(ga)?.push(b.anchor); + } else if (ga === undefined && gb !== undefined) { + group_of.set(a.anchor, gb); + members.get(gb)?.push(a.anchor); + } else if (ga !== undefined && gb !== undefined && ga !== gb) { + const moved = members.get(gb) || []; + for (const anchor of moved) group_of.set(anchor, ga); + members.get(ga)?.push(...moved); + members.delete(gb); + } + } + } + } + const child_counts = new Map(); + for (const entry of index.entries) { + if (entry.parent) + child_counts.set( + entry.parent, + (child_counts.get(entry.parent) || 0) + 1, + ); + } + const drawingParent = (entry: MapIndexEntry): string | null => { + let parent = entry.parent; + while (parent && child_counts.get(parent) === 1) { + const wrapper = index.by_anchor.get(parent); + if (!wrapper || wrapper.tag !== 'g' || wrapper.protected) break; + parent = wrapper.parent; + } + return parent; + }; + // CAD exports can wrap a chair's tiny line detail in single-child INSERT/LINE groups. + for (const entry of index.entries) { + const box = entry.bbox; + if ( + entry.tag !== 'line' || + entry.id || + entry.protected || + !entry.visible || + !entry.stroke || + entry.stroke === 'none' || + !box || + group_of.has(entry.anchor) || + glyphs.has(entry.anchor) + ) + continue; + const candidates = new Set(); + const parent = drawingParent(entry); + for (const layer of index.entries) { + const group = group_of.get(layer.anchor); + const bounds = layer.bbox; + if ( + group === undefined || + !bounds || + drawingParent(layer) !== parent + ) + continue; + if ( + Math.max(box[2], box[3]) <= + Math.min(bounds[2], bounds[3]) * 0.1 && + box[0] >= bounds[0] && + box[1] >= bounds[1] && + box[0] + box[2] <= bounds[0] + bounds[2] && + box[1] + box[3] <= bounds[1] + bounds[3] + ) + candidates.add(group); + } + if (candidates.size !== 1) continue; + const group = [...candidates][0]; + group_of.set(entry.anchor, group); + members.get(group)?.push(entry.anchor); + } + const result = new Map(); + let counter = 0; + for (const entry of index.entries) { + const group = group_of.get(entry.anchor); + if (group === undefined || result.has(entry.anchor)) continue; + counter += 1; + for (const anchor of members.get(group) || []) + result.set(anchor, `p${counter}`); + } + return result; +} + +const MENTION = /[A-Za-z0-9.]*\d[A-Za-z0-9.]*/g; +const ID_SEPARATOR = /[-_.\s]+/; +const MAX_MENTIONS = 12; + +/** Tokens of the instruction that carry a digit, such as 1.03, 01.004, G.02 or 100. */ +export function mentionedTokens(instruction: string): string[] { + const tokens = (instruction.match(MENTION) || []) + .map((token) => token.replace(/^\.+|\.+$/g, '')) + .filter((token) => token.length >= 2 || /^\d+$/.test(token)); + return unique(tokens); +} + +function idParts(value: string): string[] { + return value + .toLowerCase() + .split(ID_SEPARATOR) + .filter((part) => !!part); +} + +/** Whether the id carries the token's parts in sequence, so 1.03 names area-1.03-status but not table-01.003. */ +export function idMentions(id: string, token: string): boolean { + const needle = idParts(token); + const haystack = idParts(id); + if (!needle.length || needle.length > haystack.length) return false; + for (let start = 0; start + needle.length <= haystack.length; start++) { + if (needle.every((part, offset) => haystack[start + offset] === part)) + return true; + } + return false; +} + +/** Id'd entries the instruction names by number, in document order, at most MAX_MENTIONS. */ +export function mentionedEntries( + index: MapIndex, + instruction: string | undefined, +): MapIndexEntry[] { + if (!instruction) return []; + const tokens = mentionedTokens(instruction); + if (!tokens.length) return []; + const found = index.entries.filter( + (entry) => + !!entry.id && + tokens.some((token) => idMentions(entry.id as string, token)), + ); + return found.slice(0, MAX_MENTIONS); +} + +export function contextEntry( + entry: MapIndexEntry, + root_anchor: string | null, + container?: MapIndexEntry, + glyph = false, + piece: string | null = null, +): MapAIContextEntry { + return { + a: entry.anchor, + id: entry.id, + tag: entry.tag, + cls: entry.classes.join(' ') || null, + label: entry.label, + bbox: entry.bbox ? [...entry.bbox] : null, + p: entry.parent && entry.parent !== root_anchor ? entry.parent : null, + z: entry.z, + kind: glyph ? 'glyph' : entry.kind, + prot: entry.protected, + h: entry.hash, + fill: entry.fill, + in: container ? container.id || container.anchor : null, + piece, + }; +} + +/** Each rule as `selector{name:value;...}`, as the prompt prints them. */ +export function styleRuleStrings(rules: readonly StyleRule[]): string[] { + return rules.map( + (rule) => + `${rule.selectors.join(',')}{${formatDeclarations(rule.declarations)}}`, + ); +} + +/** The element's markup without editor attributes, cut at `max_bytes`. */ +export function subtreeMarkup(element: Element, max_bytes: number): string { + const clone = element.cloneNode(true) as Element; + stripEditorAttributes(clone); + const markup = new XMLSerializer().serializeToString(clone); + if (encoder.encode(markup).length <= max_bytes) return markup; + let cut = markup; + while (cut.length && encoder.encode(cut).length > max_bytes - 3) + cut = cut.slice(0, Math.floor(cut.length * 0.9)); + return `${cut}...`; +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values.filter((value) => !!value))]; +} + +/** + * The context an edit request carries: the index with named elements first, + * the markup of the selected and shortlisted elements, the style rules, the + * protected and duplicate ids and the markup block. Shrinks to the byte + * budget by dropping subtrees, then anonymous entries, then named entries, + * and says what went. + */ +export function buildContext( + source: ContextSource, + options: BuildContextOptions = {}, +): BuiltContext { + const index = source.index(); + const doc = source.doc; + const max_bytes = options.max_bytes ?? DEFAULT_MAX_CONTEXT_BYTES; + const max_subtree = options.max_subtree_bytes ?? MAX_SUBTREE_BYTES; + const root_entry = index.entries.find((entry) => entry.kind === 'root'); + const root_anchor = root_entry?.anchor || null; + const selection = unique(options.selection || []); + const mentioned = mentionedEntries(index, options.instruction).map( + (entry) => entry.anchor, + ); + const instruction = (options.instruction || '') + .toLocaleLowerCase() + .replace(/\s+/g, ' '); + const named_text = index.entries + .filter((entry) => entry.tag === 'text') + .filter((entry) => { + const element = doc.byAnchor(entry.anchor); + const label = element + ? textWords(element) + ?.toLocaleLowerCase() + .replace(/\s+/g, ' ') + .trim() + : ''; + return !!label && label.length >= 3 && instruction.includes(label); + }) + .slice(0, MAX_MENTIONS) + .map((entry) => entry.anchor); + const box = doc.view_box; + const outside = (options.annotations || []).some((mark) => + annotationPoints(mark).some( + ([x, y]) => + x < box.x || + y < box.y || + x > box.x + box.width || + y > box.y + box.height, + ), + ); + const include_root = + !!root_entry && + (outside || /\b(expand|extend|enlarge)\b/.test(instruction)); + const shortlist = unique([ + ...selection, + ...(options.shortlist || []), + ...(options.annotations || []).flatMap((mark) => [ + ...(mark.hits || []), + ...(mark.near || []), + ]), + ...mentioned, + ...named_text, + ...(include_root + ? index.entries + .filter( + (entry) => + entry.tag === 'g' && + /outline|walls?/i.test(entry.id || ''), + ) + .map((entry) => entry.anchor) + : []), + ]); + const size = + options.snapshot || snapshotSize(doc.view_box, options.long_side); + + const ordered = include_root + ? [root_entry!, ...orderEntries(index)] + : orderEntries(index); + const containers = containerIndex(index); + const labels = labelClusters(index, containers, (anchor) => { + const element = doc.byAnchor(anchor); + return element ? textWords(element) : null; + }); + const glyphs = new Set( + labels.flatMap((label) => + label.kind === 'glyphs' ? label.anchors : [], + ), + ); + const pieces = pieceIndex(index, glyphs); + const entries = ordered.map((entry) => + contextEntry( + entry, + root_anchor, + containers.get(entry.anchor), + glyphs.has(entry.anchor), + pieces.get(entry.anchor) ?? null, + ), + ); + const named_count = + ordered.filter(isNamedEntry).length + + (include_root && !isNamedEntry(root_entry!) ? 1 : 0); + + const subtrees: Record = {}; + for (const anchor of shortlist) { + const element = doc.byAnchor(anchor); + if (element) subtrees[anchor] = subtreeMarkup(element, max_subtree); + } + const subtree_keys = Object.keys(subtrees); + + const context: MapAIContext = { + schema: MAP_AI_CONTEXT_SCHEMA, + viewBox: viewBoxTuple(doc.view_box), + image_px: [...size.image_px], + units_per_px: size.units_per_px, + protected_ids: ordered + .filter((entry) => entry.protected && entry.id) + .map((entry) => entry.id as string), + duplicate_ids: [...index.duplicate_ids], + index: entries, + labels: labels.map((label) => ({ + kind: label.kind, + in: label.in, + anchors: [...label.anchors], + bbox: [...label.bbox] as MapAIViewBox, + fill: label.fill, + text: label.text, + })), + shortlist: subtree_keys, + subtrees, + style_rules: styleRuleStrings(doc.styleRules()), + markup: { + schema: MAP_AI_MARKUP_SCHEMA, + viewBox: viewBoxTuple(size.view_box || doc.view_box), + annotations: JSON.parse( + JSON.stringify(options.annotations || []), + ) as MapAIAnnotation[], + selected: selection, + }, + stats: { ...index.stats }, + }; + + const dropped: ContextDrops = { subtrees: 0, anonymous: 0, named: 0 }; + let bytes = bytesOf(context); + + while (bytes > max_bytes && context.shortlist.length) { + const key = context.shortlist[context.shortlist.length - 1]; + bytes -= bytesOf(key) + bytesOf(context.subtrees[key]) + 4; + context.shortlist = context.shortlist.slice(0, -1); + delete context.subtrees[key]; + dropped.subtrees += 1; + } + if (dropped.subtrees) bytes = bytesOf(context); + + const drop_tail = (floor: number, counter: keyof ContextDrops) => { + while (bytes > max_bytes && context.index.length > floor) { + const entry = context.index[context.index.length - 1]; + bytes -= bytesOf(entry) + 1; + context.index = context.index.slice(0, -1); + dropped[counter] += 1; + } + if (dropped[counter]) bytes = bytesOf(context); + }; + drop_tail(named_count, 'anonymous'); + drop_tail(named_count, 'anonymous'); + drop_tail(include_root ? 1 : 0, 'named'); + drop_tail(include_root ? 1 : 0, 'named'); + + return { context, bytes, dropped, fits: bytes <= max_bytes }; +} diff --git a/libs/map-editor/src/lib/ai/describe.ts b/libs/map-editor/src/lib/ai/describe.ts new file mode 100644 index 00000000000..8ed7e5b0cfb --- /dev/null +++ b/libs/map-editor/src/lib/ai/describe.ts @@ -0,0 +1,235 @@ +import { MapDocument, Target } from '../map-document'; +import { MapIndex, MapIndexEntry } from '../map-index'; +import { describeTarget, MapOp, targetsOf } from '../ops'; +import { elementsMatching } from '../style-resolution'; +import { format, MapEditorAiStrings } from './strings'; + +export type OpCategory = 'added' | 'changed' | 'removed' | 'question' | 'none'; + +export function opCategory(op: MapOp): OpCategory { + switch (op.op) { + case 'insert_shape': + case 'duplicate': + return 'added'; + case 'delete': + return 'removed'; + case 'ask': + return 'question'; + case 'no_change': + return 'none'; + default: + return 'changed'; + } +} + +const GLYPHS: Record = { + added: '+', + changed: '~', + removed: '−', + question: '?', + none: '·', +}; + +export function opGlyph(category: OpCategory): string { + return GLYPHS[category]; +} + +export interface DescribeContext { + doc: MapDocument; + index: MapIndex; + strings: MapEditorAiStrings; +} + +export function entryFor( + target: Target | null | undefined, + index: MapIndex, +): MapIndexEntry | undefined { + if (!target) return undefined; + if (target.a) { + const by_anchor = index.by_anchor.get(target.a); + if (by_anchor) return by_anchor; + } + if (target.id) { + return index.entries.find((entry) => entry.id === target.id); + } + return undefined; +} + +/** The element as the person knows it: `id (label)`, the id, the label, or `tag anchor`. */ +export function whoOf( + target: Target | null | undefined, + context: DescribeContext, +): string { + const entry = entryFor(target, context.index); + const id = entry?.id ?? target?.id ?? null; + const label = entry?.label ?? null; + if (id && label && label !== id) return `${id} (${label})`; + if (id) return id; + if (label) return label; + if (entry) return `${entry.tag} ${entry.anchor}`; + return describeTarget(target); +} + +function manyOf(targets: readonly Target[], context: DescribeContext): string { + if (targets.length === 1) return whoOf(targets[0], context); + const template = + targets.length === 1 + ? context.strings.describe.element + : context.strings.describe.elements; + return format(template, { count: targets.length }); +} + +function number(value: number): string { + return Number.isInteger(value) + ? String(value) + : String(Math.round(value * 100) / 100); +} + +/** Elements a selector reaches in the live document; 0 when it is not valid. */ +export function affectedCount(doc: MapDocument, selector: string): number { + try { + return elementsMatching(doc.root, selector).length; + } catch { + return 0; + } +} + +/** One line of plain words for the review row. */ +export function describeOp(op: MapOp, context: DescribeContext): string { + const { strings } = context; + const t = strings.describe; + switch (op.op) { + case 'set_attr': { + const who = whoOf(op.target, context); + return op.value === null || op.value === undefined + ? format(t.remove_attr, { name: op.name, who }) + : format(t.set_attr, { name: op.name, who, value: op.value }); + } + case 'set_style_rule': { + const current = context.doc + .styleRules() + .find((rule) => rule.selectors.includes(op.selector)); + const changes = (op.declarations || []) + .map((declaration) => + format(t.style_declaration, { + name: declaration.name, + from: current?.declarations[declaration.name] ?? '-', + to: declaration.value, + }), + ) + .join(', '); + return format(t.set_style_rule, { + selector: op.selector, + changes, + affects: format(strings.affects, { + count: affectedCount(context.doc, op.selector), + }), + }); + } + case 'set_text': + return format(t.set_text, { + who: whoOf(op.target, context), + text: op.text, + }); + case 'transform': { + const who = manyOf(targetsOf(op), context); + const moves = !!op.translate; + const scales = !!op.scale; + const rotates = typeof op.rotate === 'number' && op.rotate !== 0; + if (moves && !scales && !rotates && op.translate) { + return format(t.move, { + who, + x: number(op.translate[0]), + y: number(op.translate[1]), + }); + } + if (scales && !moves && !rotates && op.scale) { + return format(t.scale, { + who, + x: number(op.scale[0]), + y: number(op.scale[1]), + }); + } + if (rotates && !moves && !scales) { + return format(t.rotate, { + who, + degrees: number(op.rotate as number), + }); + } + return format(t.transform_many, { who }); + } + case 'delete': { + const targets = targetsOf(op); + return format(targets.length === 1 ? t.delete : t.delete_many, { + who: manyOf(targets, context), + }); + } + case 'duplicate': { + const who = whoOf(op.target, context); + const count = op.count ?? 1; + if (count > 1) { + return format(t.duplicate_many, { + who, + count, + pattern: op.id_pattern || op.new_id || '-', + }); + } + return op.new_id + ? format(t.duplicate, { who, new_id: op.new_id }) + : format(t.duplicate_unnamed, { who }); + } + case 'insert_shape': { + const parent = op.parent ? whoOf(op.parent, context) : t.map_root; + return op.new_id + ? format(t.insert_shape, { + kind: op.kind, + new_id: op.new_id, + parent, + }) + : format(t.insert_shape_unnamed, { kind: op.kind, parent }); + } + case 'set_id': { + const who = whoOf(op.target, context); + return op.new_id + ? format(t.set_id, { who, new_id: op.new_id }) + : format(t.remove_id, { who }); + } + case 'reorder': { + const who = whoOf(op.target, context); + if (op.before) + return format(t.reorder_before, { + who, + other: whoOf(op.before, context), + }); + if (op.after) + return format(t.reorder_after, { + who, + other: whoOf(op.after, context), + }); + return format( + op.position === 'first' ? t.reorder_first : t.reorder_last, + { who }, + ); + } + case 'group': { + const who = manyOf(op.targets || [], context); + return op.new_id + ? format(t.group, { who, new_id: op.new_id }) + : format(t.group_unnamed, { who }); + } + case 'ungroup': + return format(t.ungroup, { who: whoOf(op.target, context) }); + case 'replace_element': + return format(t.replace_element, { + who: whoOf(op.target, context), + }); + case 'replace_path': + return format(t.replace_path, { who: whoOf(op.target, context) }); + case 'no_change': + return format(t.no_change, { reason: op.reason }); + case 'ask': + return format(t.ask, { question: op.question }); + default: + return describeTarget(null); + } +} diff --git a/libs/map-editor/src/lib/ai/index.ts b/libs/map-editor/src/lib/ai/index.ts new file mode 100644 index 00000000000..cf45ad2c4e3 --- /dev/null +++ b/libs/map-editor/src/lib/ai/index.ts @@ -0,0 +1,12 @@ +export * from './ai-panel.component'; +export * from './api'; +export * from './context'; +export * from './describe'; +export * from './preview'; +export * from './proposal'; +export * from './session-contract'; +export * from './snapshot'; +export * from './state'; +export * from './strings'; +export * from './thread'; +export * from './types'; diff --git a/libs/map-editor/src/lib/ai/panel-icons.ts b/libs/map-editor/src/lib/ai/panel-icons.ts new file mode 100644 index 00000000000..0875c503ab2 --- /dev/null +++ b/libs/map-editor/src/lib/ai/panel-icons.ts @@ -0,0 +1,14 @@ +/** Glyphs the AI panel draws inline as 24-unit path data, so the lib needs no icon font. */ +export const PANEL_ICONS = { + check: 'M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z', + undo: 'M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8', + close: 'M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z', + send: 'M2.01 21 23 12 2.01 3 2 10l15 2-15 2z', + lock: 'M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2m-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2m3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1z', + error: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m1 15h-2v-2h2zm0-4h-2V7h2z', + needs: 'm19 15-6 6-1.42-1.42L15.17 16H4V4h2v10h9.17l-3.59-3.58L13 9z', + warning: 'M1 21h22L12 2zm12-3h-2v-2h2zm0-4h-2v-4h2z', + question: 'M11 18h2v-2h-2zm1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8m0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4', +} as const; + +export type PanelIcon = keyof typeof PANEL_ICONS; diff --git a/libs/map-editor/src/lib/ai/preview.ts b/libs/map-editor/src/lib/ai/preview.ts new file mode 100644 index 00000000000..332f9bf882b --- /dev/null +++ b/libs/map-editor/src/lib/ai/preview.ts @@ -0,0 +1,97 @@ +import { BBox } from '../geometry'; +import { MapIndex } from '../map-index'; +import { OpCategory } from './describe'; +import { Proposal } from './proposal'; +import { format, MapEditorAiStrings } from './strings'; + +/** One numbered change previewed on the map. */ +export interface PreviewMarker { + /** 1-based position of the row in the proposal. */ + n: number; + anchors: string[]; + category: OpCategory; + /** Ticked, so applied to the map; an unticked row keeps its number but no outline. */ + applied: boolean; + /** Where the elements stood before the preview, used once the preview has removed them. */ + box_before?: BBox | null; +} + +export interface PreviewCount { + category: OpCategory; + label: string; + count: number; +} + +/** What the canvas shows for a proposal previewed on the map. */ +export interface PreviewSummary { + title: string; + note: string; + counts: PreviewCount[]; + markers: PreviewMarker[]; +} + +/** The box around every listed element in the index; null when none of them has one. */ +export function unionBox( + index: MapIndex, + anchors: readonly string[], +): BBox | null { + let box: BBox | null = null; + for (const anchor of anchors) { + const bbox = index.by_anchor.get(anchor)?.bbox; + if (!bbox) continue; + if (!box) { + box = [...bbox] as BBox; + continue; + } + const x = Math.min(box[0], bbox[0]); + const y = Math.min(box[1], bbox[1]); + const x2 = Math.max(box[0] + box[2], bbox[0] + bbox[2]); + const y2 = Math.max(box[1] + box[3], bbox[1] + bbox[3]); + box = [x, y, x2 - x, y2 - y]; + } + return box; +} + +const COUNTED: readonly OpCategory[] = ['added', 'changed', 'removed']; + +/** The markers and counts for the proposal's actionable rows; null when there are none. */ +export function previewSummary( + proposal: Proposal, + strings: MapEditorAiStrings, +): PreviewSummary | null { + const rows = proposal.rows.filter((row) => row.actionable); + if (!rows.length) return null; + const markers = rows.map((row) => ({ + n: row.index + 1, + anchors: [...row.anchors], + category: row.category, + applied: row.checked && !row.locked, + box_before: row.box_before ?? null, + })); + const on_map = rows.filter((row) => row.checked && !row.locked); + const counts = COUNTED.map((category) => ({ + category, + count: on_map.filter((row) => row.category === category).length, + label: '', + })) + .filter((entry) => entry.count > 0) + .map((entry) => ({ + ...entry, + label: format(strings.group_label, { + count: entry.count, + kind: strings.categories[entry.category], + }), + })); + return { + title: + on_map.length === rows.length + ? format(strings.proposal_title, { count: rows.length }) + : format(strings.preview_partial_title, { + applied: on_map.length, + count: rows.length, + }), + note: strings.outcomes.previewing, + counts, + markers, + }; +} diff --git a/libs/map-editor/src/lib/ai/proposal.ts b/libs/map-editor/src/lib/ai/proposal.ts new file mode 100644 index 00000000000..ed718acecd0 --- /dev/null +++ b/libs/map-editor/src/lib/ai/proposal.ts @@ -0,0 +1,612 @@ +import { applyOps } from '../apply'; +import { BBox } from '../geometry'; +import { idsIn } from '../ids'; +import { MapDocument, Target } from '../map-document'; +import { MapOp, OpKind, targetsOf } from '../ops'; +import { elementsMatching } from '../style-resolution'; +import { + validateProposal, + ValidationContext, + ValidationIssue, + ValidationResult, +} from '../validate'; +import { describeOp, opCategory, OpCategory, opGlyph } from './describe'; +import { MapEditorSessionLike } from './session-contract'; +import { format, MapEditorAiStrings } from './strings'; +import { MapAIJob, MapAIResolveDecision, MapAIResolveRequest } from './types'; + +/** Rows collapse into groups by kind above this many. */ +export const GROUP_THRESHOLD = 20; +const MAX_RULE_ANCHORS = 200; + +/** `op-N`, N the op's 1-based position. */ +export function opId(index: number): string { + return `op-${index + 1}`; +} + +/** The job's ops with their ids and the server's reasons written onto them. */ +export function opsWithServerIssues( + job: Pick, +): MapOp[] { + return (job.ops || []).map((op, index) => { + if (typeof op !== 'object' || op === null || Array.isArray(op)) + return op; + const id = opId(index); + const reason = job.server_issues?.[id]; + return { + ...op, + id, + ...(reason ? { server_issue: reason } : {}), + } as MapOp; + }); +} + +/** Every element an op refers to: its targets plus parent, before and after references. */ +export function referencedTargets(op: MapOp): Target[] { + const targets = [...targetsOf(op)]; + if (op.op === 'insert_shape') { + if (op.parent) targets.push(op.parent); + if (op.after) targets.push(op.after); + } else if (op.op === 'reorder') { + if (op.before) targets.push(op.before); + if (op.after) targets.push(op.after); + } + return targets; +} + +/** + * For each op, the indices of earlier ops it depends on: an op whose target + * is an element an earlier op creates (by anchor or by new id) needs that op + * applied first. Connected ID renames are accepted or rejected together. + */ +export function deriveDependencies( + doc: MapDocument, + ops: readonly MapOp[], +): number[][] { + const trial = doc.clone(); + const original_ids = idsIn(doc.root); + const original_anchors = new Set( + doc.elements().map((element) => doc.anchorOf(element)), + ); + const created_by_anchor = new Map(); + const created_by_id = new Map(); + const result: number[][] = []; + ops.forEach((op, index) => { + const depends = new Set(); + if (typeof op === 'object' && op !== null) { + for (const target of referencedTargets(op)) { + if (!target) continue; + if (target.a && !original_anchors.has(target.a)) { + const creator = created_by_anchor.get(target.a); + if (creator !== undefined && creator < index) + depends.add(creator); + } + if (target.id && !original_ids.has(target.id)) { + const creator = created_by_id.get(target.id); + if (creator !== undefined && creator < index) + depends.add(creator); + } + } + } + result.push([...depends].sort((a, b) => a - b)); + let applied; + try { + applied = applyOps(trial, [op], { geometry: trial.geometry }); + } catch { + return; + } + for (const step of applied.applied) { + for (const anchor of step.created) { + created_by_anchor.set(anchor, index); + const id = trial.byAnchor(anchor)?.getAttribute('id'); + if (id && !original_ids.has(id)) created_by_id.set(id, index); + } + if ( + step.op.op === 'set_id' && + step.op.new_id && + !original_ids.has(step.op.new_id) + ) { + created_by_id.set(step.op.new_id, index); + } + } + }); + const identity_changes = new Map(); + ops.forEach((op, index) => { + if (!op || typeof op !== 'object') return; + const keys: string[] = []; + if (op.op === 'set_id' && op.target) { + const resolution = doc.resolve(op.target); + const old_id = resolution.ok + ? resolution.element.getAttribute('id') + : op.target.id; + const anchor = resolution.ok + ? doc.anchorOf(resolution.element) + : op.target.a; + if (anchor) keys.push(`anchor:${anchor}`); + if (old_id) keys.push(`id:${old_id}`); + } + if ('new_id' in op && op.new_id) keys.push(`id:${op.new_id}`); + for (const key of new Set(keys)) { + const related = identity_changes.get(key) || []; + for (const other of related) { + if (op.op !== 'set_id' && ops[other].op !== 'set_id') continue; + if (!result[index].includes(other)) result[index].push(other); + if (!result[other].includes(index)) result[other].push(index); + } + related.push(index); + identity_changes.set(key, related); + } + }); + const expansion = ops.findIndex( + (op) => op.op === 'set_attr' && op.name === 'viewBox', + ); + if (expansion >= 0) { + const related = ops + .map((op, index) => + op.op !== 'ask' && op.op !== 'no_change' ? index : -1, + ) + .filter((index) => index >= 0); + for (const index of related) + for (const other of related) + if (index !== other && !result[index].includes(other)) + result[index].push(other); + } + return result.map((needs) => needs.sort((a, b) => a - b)); +} + +export interface ProposalRow { + index: number; + /** `op-N`, what resolve reports. */ + id: string; + op: MapOp; + kind: OpKind; + category: OpCategory; + glyph: string; + kind_word: string; + text: string; + /** Anchors of the elements the op touches in the live document. */ + anchors: string[]; + issues: ValidationIssue[]; + actionable: boolean; + locked: boolean; + checked: boolean; + /** Applied in the trial, or on the map once previewed. */ + applied: boolean; + /** Where the row's elements stood before the preview, for elements the preview removes. */ + box_before?: BBox | null; + affected?: number; + note?: string; + server_issue: string | null; + depends_on: number[]; + dependents: number[]; +} + +export interface ProposalGroup { + key: string; + label: string; + category: OpCategory; + kind: OpKind; + indices: number[]; + checked: 'all' | 'some' | 'none'; + locked: boolean; +} + +export type ProposalVerdict = + | 'empty' + | 'questions' + | 'ready' + | 'partial' + | 'blocked' + | 'nothing_accepted'; + +export interface Proposal { + job: MapAIJob; + /** The job's ops with ids and server reasons on them. */ + ops: MapOp[]; + summary: string; + questions: string[]; + rows: ProposalRow[]; + batch_issues: ValidationIssue[]; + blocking: boolean; + element_delta: number; + byte_delta: number; + /** The map as it was when the proposal arrived, for the Before thumbnail. */ + before_markup: string | null; + /** The map with the accepted set applied, for the After thumbnail. */ + trial_markup: string | null; + verdict: ProposalVerdict; + /** Indices of the rows that are ticked. */ + accepted: number[]; + /** Actionable rows that cannot be applied. */ + invalid_count: number; + dependencies: number[][]; + /** Rows unticked only because a row they need was unticked; ticking that row again restores them. */ + held?: number[]; + groups: ProposalGroup[] | null; +} + +export interface BuildProposalOptions { + /** The person's ticks; the validator's defaults when absent. */ + accepted?: ReadonlySet; + /** Protected ids the person has unlocked. */ + unlocked?: Iterable; + dependencies?: number[][]; + held?: Iterable; + /** Serialise the document and the trial for the thumbnails. Default true. */ + trial_markup?: boolean; +} + +function sameSet(a: ReadonlySet, b: ReadonlySet): boolean { + if (a.size !== b.size) return false; + for (const value of a) if (!b.has(value)) return false; + return true; +} + +/** Drops ticks whose dependencies are unticked, to a fixpoint. */ +export function cascadeDependencies( + accepted: ReadonlySet, + dependencies: readonly (readonly number[])[], +): Set { + const next = new Set(accepted); + let changed = true; + while (changed) { + changed = false; + for (const index of [...next]) { + const needs = dependencies[index] || []; + if (needs.some((dependency) => !next.has(dependency))) { + next.delete(index); + changed = true; + } + } + } + return next; +} + +function resolvedAnchors(op: MapOp, doc: MapDocument): string[] { + const anchors = new Set(); + if (op.op === 'set_style_rule') { + try { + for (const element of elementsMatching(doc.root, op.selector).slice( + 0, + MAX_RULE_ANCHORS, + )) { + const anchor = doc.anchorOf(element); + if (anchor) anchors.add(anchor); + } + } catch { + return []; + } + return [...anchors]; + } + const targets = + op.op === 'insert_shape' ? targetsOf(op) : referencedTargets(op); + for (const target of targets) { + const resolution = doc.resolve(target); + if (resolution.ok) { + const anchor = doc.anchorOf(resolution.element); + if (anchor) anchors.add(anchor); + } + } + return [...anchors]; +} + +export function verdictOf( + rows: readonly Pick[], + accepted: ReadonlySet, + blocking: boolean, + questions: readonly string[], +): ProposalVerdict { + const actionable = rows.filter((row) => row.actionable).length; + if (!actionable) { + return questions.length || rows.some((row) => row.op.op === 'ask') + ? 'questions' + : 'empty'; + } + if (blocking) return 'blocked'; + if (!accepted.size) return 'nothing_accepted'; + return accepted.size < actionable ? 'partial' : 'ready'; +} + +/** Rows by category and kind, in order of first appearance. */ +export function groupRows( + rows: readonly ProposalRow[], + strings: MapEditorAiStrings, +): ProposalGroup[] { + const groups = new Map(); + for (const row of rows) { + const key = `${row.category}:${row.kind}`; + let group = groups.get(key); + if (!group) { + group = { + key, + label: '', + category: row.category, + kind: row.kind, + indices: [], + checked: 'none', + locked: true, + }; + groups.set(key, group); + } + group.indices.push(row.index); + } + return [...groups.values()].map((group) => { + const members = group.indices.map((index) => rows[index]); + const tickable = members.filter((row) => row.actionable && !row.locked); + const ticked = tickable.filter((row) => row.checked).length; + return { + ...group, + label: format(strings.group_label, { + count: group.indices.length, + kind: strings.kinds[group.kind] ?? group.kind, + }), + locked: tickable.length === 0, + checked: + ticked === 0 + ? 'none' + : ticked === tickable.length + ? 'all' + : 'some', + }; + }); +} + +/** The parts of a session a proposal is built and validated against. */ +export type ProposalBase = Pick< + MapEditorSessionLike, + 'doc' | 'index' | 'protected' | 'render_check' | 'geometry' +>; + +/** + * The review model for a finished job: one row per op with its words, the + * validator's verdict and the dependency links, plus the batch result for + * the ticked set. Validates once with the validator's defaults, then again + * when the ticked set differs from what the first trial applied. + */ +export async function buildProposal( + job: MapAIJob, + session: ProposalBase, + strings: MapEditorAiStrings, + options: BuildProposalOptions = {}, +): Promise { + const ops = opsWithServerIssues(job); + const index = session.index(); + const describe_context = { doc: session.doc, index, strings }; + const dependencies = + options.dependencies ?? deriveDependencies(session.doc, ops); + const base: ValidationContext = { + protected: session.protected, + unlocked: options.unlocked, + render_check: session.render_check, + geometry: session.geometry, + }; + const validate = (accepted?: ReadonlySet) => + validateProposal(session.doc, ops, { ...base, accepted }); + + const complete_validation = await validate(); + let validation: ValidationResult = complete_validation; + const tickable = (i: number) => + !!validation.ops[i] && + validation.ops[i].actionable && + !validation.ops[i].locked; + let accepted: Set; + if (options.accepted) { + accepted = new Set([...options.accepted].filter(tickable)); + } else { + accepted = new Set( + validation.ops + .filter((op) => op.checked_by_default) + .map((op) => op.index), + ); + } + accepted = cascadeDependencies(accepted, dependencies); + const applied_in_trial = new Set( + validation.ops.filter((op) => op.applied).map((op) => op.index), + ); + if (!sameSet(accepted, applied_in_trial)) + validation = await validate(accepted); + + const dependents = dependencies.map(() => [] as number[]); + dependencies.forEach((needs, i) => { + for (const need of needs) dependents[need]?.push(i); + }); + + const rows: ProposalRow[] = validation.ops.map((trial_result, i) => { + const result = accepted.has(i) + ? trial_result + : complete_validation.ops[i]; + const op = ops[i]; + const category = opCategory(op); + const kind: OpKind = op.op; + return { + index: i, + id: typeof op.id === 'string' && op.id ? op.id : opId(i), + op, + kind, + category, + glyph: opGlyph(category), + kind_word: strings.kinds[kind] ?? String(kind), + text: describeOp(op, describe_context), + anchors: resolvedAnchors(op, session.doc), + issues: result.issues, + actionable: result.actionable, + locked: result.locked, + checked: accepted.has(i), + applied: !validation.blocking && trial_result.applied, + ...(result.affected !== undefined + ? { affected: result.affected } + : {}), + ...(result.note ? { note: result.note } : {}), + server_issue: + typeof op.server_issue === 'string' && op.server_issue + ? op.server_issue + : null, + depends_on: dependencies[i] || [], + dependents: dependents[i] || [], + }; + }); + + const questions = [ + ...(job.questions || []).filter((q) => typeof q === 'string' && q), + ...ops + .filter((op) => op.op === 'ask') + .map((op) => (op.op === 'ask' ? op.question : '')) + .filter((q) => !!q), + ]; + const verdict = verdictOf(rows, accepted, validation.blocking, questions); + return { + job, + ops, + summary: job.summary || '', + questions: [...new Set(questions)], + rows, + batch_issues: validation.issues, + blocking: validation.blocking, + element_delta: validation.element_delta, + byte_delta: validation.byte_delta, + before_markup: + options.trial_markup === false ? null : session.doc.serialise(), + trial_markup: + options.trial_markup === false + ? null + : validation.trial?.serialise() || null, + verdict, + accepted: [...accepted].sort((a, b) => a - b), + invalid_count: rows.filter((row) => row.actionable && row.locked) + .length, + dependencies, + held: [...new Set(options.held ?? [])] + .filter((i) => !accepted.has(i)) + .sort((a, b) => a - b), + groups: rows.length > GROUP_THRESHOLD ? groupRows(rows, strings) : null, + }; +} + +export function acceptedSet(proposal: Proposal): Set { + return new Set(proposal.accepted); +} + +/** + * The ticked set after one row changes. Ticking a row ticks the rows it + * needs and the held rows whose needs are now all ticked; unticking a row + * unticks the rows that need it. + */ +export function toggleRow( + proposal: Proposal, + index: number, + checked: boolean, +): Set { + const next = acceptedSet(proposal); + const row = proposal.rows[index]; + if (!row || !row.actionable || row.locked) return next; + if (checked) { + next.add(index); + const stack = [...row.depends_on]; + while (stack.length) { + const need = stack.pop() as number; + const needed = proposal.rows[need]; + if (!needed || needed.locked || !needed.actionable) continue; + if (next.has(need)) continue; + next.add(need); + stack.push(...needed.depends_on); + } + restoreHeld(proposal, next); + } else { + next.delete(index); + const stack = [...row.dependents]; + while (stack.length) { + const dependent = stack.pop() as number; + if (!next.has(dependent)) continue; + next.delete(dependent); + stack.push(...(proposal.rows[dependent]?.dependents || [])); + } + } + return next; +} + +function restoreHeld(proposal: Proposal, next: Set): void { + const held = (proposal.held ?? []).filter((i) => !next.has(i)); + let restored = true; + while (restored) { + restored = false; + for (const index of held) { + const row = proposal.rows[index]; + if (next.has(index) || !row || row.locked || !row.actionable) + continue; + if (!row.depends_on.length) continue; + if (row.depends_on.every((need) => next.has(need))) { + next.add(index); + restored = true; + } + } + } +} + +/** + * The held rows after a change of ticks: rows that went off as a side effect + * join them, rows that are ticked or were unticked directly leave them. + */ +export function heldAfter( + proposal: Proposal, + accepted: ReadonlySet, + unticked: Iterable = [], +): Set { + const held = new Set(proposal.held ?? []); + for (const index of proposal.accepted) + if (!accepted.has(index)) held.add(index); + for (const index of unticked) held.delete(index); + for (const index of [...held]) if (accepted.has(index)) held.delete(index); + return held; +} + +/** The ticked set after a whole group changes, member by member. */ +export function toggleGroup( + proposal: Proposal, + group: ProposalGroup, + checked: boolean, +): Set { + let current = proposal; + let next = acceptedSet(proposal); + for (const index of group.indices) { + next = toggleRow(current, index, checked); + current = { + ...current, + accepted: [...next].sort((a, b) => a - b), + }; + } + return next; +} + +/** applied when every actionable row went on, partial when some did, rejected when none. */ +export function decisionFor(proposal: Proposal): MapAIResolveDecision { + const actionable = proposal.rows.filter((row) => row.actionable); + const applied = actionable.filter((row) => row.checked && row.applied); + if (!applied.length) return 'rejected'; + return applied.length === actionable.length ? 'applied' : 'partial'; +} + +/** The body of `POST /jobs/:id/resolve`. */ +export function resolvePayload( + proposal: Proposal, + decision: MapAIResolveDecision, + reasons: readonly string[] = [], + saved_upload_id: string | null = null, +): MapAIResolveRequest { + const actionable = proposal.rows.filter((row) => row.actionable); + const accepted = + decision === 'rejected' + ? [] + : actionable + .filter((row) => row.checked && row.applied) + .map((row) => row.id); + const rejected = actionable + .filter((row) => !accepted.includes(row.id)) + .map((row) => row.id); + return { + decision, + accepted_op_ids: accepted, + rejected_op_ids: rejected, + ...(reasons.length ? { reasons: [...reasons] } : {}), + saved_upload_id, + }; +} diff --git a/libs/map-editor/src/lib/ai/session-contract.ts b/libs/map-editor/src/lib/ai/session-contract.ts new file mode 100644 index 00000000000..75adc926a9e --- /dev/null +++ b/libs/map-editor/src/lib/ai/session-contract.ts @@ -0,0 +1,56 @@ +import { Signal } from '@angular/core'; +import { ApplyResult } from '../apply'; +import { GeometryProvider } from '../geometry'; +import { MapDocument } from '../map-document'; +import { MapIndex } from '../map-index'; +import { MapOp } from '../ops'; +import { ProtectedIdSet } from '../protected-ids'; +import { RenderCheck } from '../render-check'; +import { PreviewSummary } from './preview'; +import { MapAIAnnotation } from './types'; + +export interface SessionApplyOptions { + /** Indices of ops to leave out, such as rows the person unticked. */ + skip?: ReadonlySet; +} + +/** + * What the AI panel needs from the editor session hosting it. The canvas + * lane's `MapEditorSession` satisfies this structurally; the panel never + * imports it. + */ +export interface MapEditorSessionLike { + /** The live document; undo, redo and restore swap its root in place. */ + readonly doc: MapDocument; + readonly annotations?: Signal; + /** Bumps on every document change. */ + readonly version: Signal; + /** Anchors of the selected elements. */ + readonly selection: Signal; + /** Anchor under the pointer or highlighted by a panel row. */ + readonly hover: Signal; + /** The proposal previewed on the map, when the host draws pins for it. */ + readonly preview?: Signal; + setPreview?(summary: PreviewSummary | null): void; + readonly protected: ProtectedIdSet; + readonly geometry: GeometryProvider; + readonly render_check: RenderCheck; + /** The element index with bounding boxes, cached per version. */ + index(): MapIndex; + select(anchors: readonly string[]): void; + setHover(anchor: string | null): void; + /** One history entry, then the ops, then a version bump. */ + apply( + ops: readonly MapOp[], + label: string, + options?: SessionApplyOptions, + ): ApplyResult; + undo(): boolean; + redo(): boolean; + readonly can_undo: Signal; + readonly can_redo: Signal; + /** How many changes deep the map is; see `MapHistory.position`. */ + historyPosition(): number; + /** Differs from the loaded document. */ + readonly dirty: Signal; +} diff --git a/libs/map-editor/src/lib/ai/snapshot.ts b/libs/map-editor/src/lib/ai/snapshot.ts new file mode 100644 index 00000000000..28fff287c97 --- /dev/null +++ b/libs/map-editor/src/lib/ai/snapshot.ts @@ -0,0 +1,248 @@ +import { SVG_NS } from '../constants'; +import { MapDocument, stripEditorAttributes, ViewBox } from '../map-document'; +import { annotationPoints, drawAnnotations } from './annotations'; +import { MapAIAnnotation } from './types'; + +export const SNAPSHOT_LONG_SIDE = 2048; +export const SNAPSHOT_OUTLINE_COLOUR = '#d946ef'; +export const SNAPSHOT_TAG_COLOUR = '#c2410c'; +const DEFAULT_RENDER_TIMEOUT_MS = 15_000; + +export interface SnapshotSize { + view_box?: ViewBox; + /** Width and height in pixels. */ + image_px: [number, number]; + /** User units per pixel, to four places. */ + units_per_px: number; +} + +/** Pixel size at the viewBox aspect ratio with the long side at `long_side`. */ +export function snapshotSize( + view_box: ViewBox, + long_side: number = SNAPSHOT_LONG_SIDE, +): SnapshotSize { + const { width, height } = view_box; + const landscape = width >= height; + const long = Math.max(1, Math.round(long_side)); + const short = Math.max( + 1, + Math.round( + (long * (landscape ? height : width)) / + (landscape ? width : height), + ), + ); + const image_px: [number, number] = landscape + ? [long, short] + : [short, long]; + const units_per_px = Math.round((width / image_px[0]) * 10_000) / 10_000; + return { image_px, units_per_px }; +} + +export interface SnapshotMarkupOptions { + /** Anchors to outline, with their ids as labels. */ + selection?: readonly string[]; + annotations?: readonly MapAIAnnotation[]; + /** Anchors to mark with a small id tag at the top left of their box. */ + tags?: readonly string[]; + long_side?: number; + colour?: string; +} + +export interface SnapshotMarkup extends SnapshotSize { + /** The map sized in pixels with the outlines drawn on top, anchors stripped. */ + markup: string; + /** Anchors that were outlined; the rest had no bounding box. */ + outlined: string[]; +} + +/** + * The document as it will be rasterised: a clone with explicit pixel width + * and height, the selection outlined in a group appended last so it paints + * above everything, and no editor attributes. + */ +export function snapshotMarkup( + doc: MapDocument, + options: SnapshotMarkupOptions = {}, +): SnapshotMarkup { + const bounds = { ...doc.view_box }; + const points = (options.annotations || []).flatMap(annotationPoints); + for (const [x, y] of points) { + const right = Math.max(bounds.x + bounds.width, x); + const bottom = Math.max(bounds.y + bounds.height, y); + bounds.x = Math.min(bounds.x, x); + bounds.y = Math.min(bounds.y, y); + bounds.width = right - bounds.x; + bounds.height = bottom - bounds.y; + } + if ( + bounds.x !== doc.view_box.x || + bounds.y !== doc.view_box.y || + bounds.width !== doc.view_box.width || + bounds.height !== doc.view_box.height + ) { + const margin = Math.max(bounds.width, bounds.height) * 0.015; + bounds.x -= margin; + bounds.y -= margin; + bounds.width += margin * 2; + bounds.height += margin * 2; + } + const size = snapshotSize(bounds, options.long_side); + const clone = doc.root.cloneNode(true) as SVGSVGElement; + const owner = clone.ownerDocument; + const colour = options.colour || SNAPSHOT_OUTLINE_COLOUR; + const { width, height } = doc.view_box; + const stroke_width = Math.max(0.5, Math.max(width, height) * 0.0025); + const font_size = Math.max(8 * stroke_width, 6); + const outlined: string[] = []; + const overlay = owner.createElementNS(SVG_NS, 'g'); + for (const anchor of options.selection || []) { + const element = doc.byAnchor(anchor); + if (!element) continue; + const bbox = doc.geometry.bboxOf(element); + if (!bbox) continue; + const [x, y, w, h] = bbox; + const rect = owner.createElementNS(SVG_NS, 'rect'); + rect.setAttribute('x', String(x)); + rect.setAttribute('y', String(y)); + rect.setAttribute('width', String(w)); + rect.setAttribute('height', String(h)); + rect.setAttribute('fill', 'none'); + rect.setAttribute('stroke', colour); + rect.setAttribute('stroke-width', String(stroke_width)); + overlay.appendChild(rect); + const text = owner.createElementNS(SVG_NS, 'text'); + text.setAttribute('x', String(x)); + text.setAttribute('y', String(y - stroke_width * 2)); + text.setAttribute('font-size', String(font_size)); + text.setAttribute('font-family', 'sans-serif'); + text.setAttribute('fill', colour); + text.setAttribute('stroke', '#ffffff'); + text.setAttribute('stroke-width', String(stroke_width)); + text.setAttribute('paint-order', 'stroke'); + text.textContent = element.getAttribute('id') || anchor; + overlay.appendChild(text); + outlined.push(anchor); + } + const tagged = new Set(outlined); + const tag_size = Math.max(font_size * 0.6, 5); + for (const anchor of options.tags || []) { + if (tagged.has(anchor)) continue; + const element = doc.byAnchor(anchor); + const id = element?.getAttribute('id'); + if (!element || !id) continue; + const bbox = doc.geometry.bboxOf(element); + if (!bbox) continue; + const [x, y] = bbox; + const text = owner.createElementNS(SVG_NS, 'text'); + text.setAttribute('x', String(x + stroke_width)); + text.setAttribute('y', String(y + tag_size + stroke_width)); + text.setAttribute('font-size', String(tag_size)); + text.setAttribute('font-family', 'sans-serif'); + text.setAttribute('fill', SNAPSHOT_TAG_COLOUR); + text.setAttribute('stroke', '#ffffff'); + text.setAttribute('stroke-width', String(stroke_width * 0.6)); + text.setAttribute('paint-order', 'stroke'); + text.textContent = id; + overlay.appendChild(text); + tagged.add(anchor); + } + drawAnnotations(overlay, options.annotations || [], size.units_per_px); + if (overlay.childNodes.length) clone.appendChild(overlay); + clone.setAttribute( + 'viewBox', + `${bounds.x} ${bounds.y} ${bounds.width} ${bounds.height}`, + ); + clone.setAttribute('width', String(size.image_px[0])); + clone.setAttribute('height', String(size.image_px[1])); + stripEditorAttributes(clone); + return { + ...size, + view_box: bounds, + markup: new XMLSerializer().serializeToString(clone), + outlined, + }; +} + +export interface RasteriseOptions { + /** `image/png` by default. */ + type?: string; + quality?: number; + background?: string; + timeout_ms?: number; +} + +/** Draws SVG markup through an Image onto a canvas of the given pixel size. Browser only. */ +export function rasterise( + markup: string, + [width, height]: [number, number], + options: RasteriseOptions = {}, +): Promise { + const url = URL.createObjectURL( + new Blob([markup], { type: 'image/svg+xml' }), + ); + return new Promise((resolve, reject) => { + const image = new Image(); + let settled = false; + const finish = (error: Error | null, blob?: Blob) => { + if (settled) return; + settled = true; + clearTimeout(timer); + URL.revokeObjectURL(url); + if (error || !blob) reject(error || new Error('no snapshot')); + else resolve(blob); + }; + const timer = setTimeout( + () => finish(new Error('the map did not finish rendering in time')), + options.timeout_ms ?? DEFAULT_RENDER_TIMEOUT_MS, + ); + image.onload = () => { + try { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + if (!context) throw new Error('no 2d canvas context'); + context.fillStyle = options.background || '#ffffff'; + context.fillRect(0, 0, width, height); + context.drawImage(image, 0, 0, width, height); + canvas.toBlob( + (blob) => + finish( + blob + ? null + : new Error( + 'the snapshot could not be encoded', + ), + blob || undefined, + ), + options.type || 'image/png', + options.quality, + ); + } catch (error) { + finish( + error instanceof Error ? error : new Error(String(error)), + ); + } + }; + image.onerror = () => + finish(new Error('the browser could not draw the map')); + image.src = url; + }); +} + +export interface RenderSnapshotOptions + extends SnapshotMarkupOptions, RasteriseOptions {} + +export interface Snapshot extends SnapshotMarkup { + blob: Blob; +} + +/** The marked up map as an image the server can send to the model. Browser only. */ +export async function renderSnapshot( + doc: MapDocument, + options: RenderSnapshotOptions = {}, +): Promise { + const prepared = snapshotMarkup(doc, options); + const blob = await rasterise(prepared.markup, prepared.image_px, options); + return { ...prepared, blob }; +} diff --git a/libs/map-editor/src/lib/ai/state.ts b/libs/map-editor/src/lib/ai/state.ts new file mode 100644 index 00000000000..7bfacbd854e --- /dev/null +++ b/libs/map-editor/src/lib/ai/state.ts @@ -0,0 +1,577 @@ +import { Proposal } from './proposal'; +import { format, MapEditorAiStrings } from './strings'; +import { MapAICapabilities, MapAIJob } from './types'; + +export type MapAiErrorKind = + | 'not_configured' + | 'model_cannot' + | 'quota' + | 'busy' + | 'forbidden' + | 'refused' + | 'timeout' + | 'vendor' + | 'invalid' + | 'network' + | 'request' + | 'unknown'; + +export interface MapAiError { + kind: MapAiErrorKind; + /** Written for the person. */ + message: string; + /** Sending the same request again may work. */ + retryable: boolean; + job?: MapAIJob | null; +} + +export type MapAiPhase = + | { kind: 'idle' } + | { kind: 'checking' } + | { kind: 'unavailable'; reason: string; hidden: boolean } + | { kind: 'composing' } + | { kind: 'submitting' } + | { + kind: 'running'; + job: MapAIJob; + started_at: number; + cancelling: boolean; + } + | { + kind: 'reviewing'; + proposal: Proposal; + validating: boolean; + /** The earlier preview is still on the map while the new selection is checked. */ + on_map?: boolean; + } + | { kind: 'applied'; proposal: Proposal } + | { kind: 'error'; error: MapAiError } + | { kind: 'cancelled'; job: MapAIJob | null }; + +export type ProposalOutcome = + | 'previewing' + | 'not_applied' + | 'kept' + | 'partial' + | 'undone' + | 'questions' + | 'empty' + | 'refined' + | 'abandoned'; + +export type ThreadTurn = + | { + kind: 'person'; + id: string; + instruction: string; + selected: number; + snapshot: boolean; + at: number; + } + | { + kind: 'proposal'; + id: string; + job_id: string; + proposal: Proposal; + outcome: ProposalOutcome; + reasons: string[]; + at: number; + } + | { + kind: 'status'; + id: string; + text: string; + error: MapAiError | null; + at: number; + }; + +/** The provider and model the next request asks for; null leaves it to the server. */ +export interface MapAiModelChoice { + provider_id: string; + model: string; +} + +export interface MapAiModelOption extends MapAiModelChoice { + provider_name: string; + key: string; +} + +export function modelKey(choice: MapAiModelChoice): string { + return `${choice.provider_id}|${choice.model}`; +} + +/** Every model the capabilities offer, provider by provider. */ +export function modelOptions( + capabilities: MapAICapabilities | null | undefined, +): MapAiModelOption[] { + if (!capabilities?.enabled) return []; + return capabilities.providers.flatMap((provider) => { + const models = provider.text_models?.length + ? provider.text_models + : [provider.text_model]; + return models.map((model) => ({ + provider_id: provider.id, + provider_name: provider.name, + model, + key: modelKey({ provider_id: provider.id, model }), + })); + }); +} + +/** The default provider's own model. */ +export function defaultChoice( + capabilities: MapAICapabilities | null | undefined, +): MapAiModelChoice | null { + if (!capabilities?.enabled) return null; + const provider = + capabilities.providers.find( + (candidate) => candidate.id === capabilities.default_provider_id, + ) ?? capabilities.providers[0]; + return provider + ? { provider_id: provider.id, model: provider.text_model } + : null; +} + +/** The choice while the capabilities still offer it. */ +export function offeredChoice( + choice: MapAiModelChoice | null, + capabilities: MapAICapabilities | null | undefined, +): MapAiModelChoice | null { + if (!choice) return null; + const key = modelKey(choice); + return modelOptions(capabilities).some((option) => option.key === key) + ? choice + : null; +} + +/** The model the next request asks for: the choice, else the default. */ +export function effectiveChoice(state: MapAiState): MapAiModelChoice | null { + return state.choice ?? defaultChoice(state.capabilities); +} + +export interface MapAiState { + phase: MapAiPhase; + capabilities: MapAICapabilities | null; + /** The model picked in the panel; null for the server's default. */ + choice: MapAiModelChoice | null; + turns: ThreadTurn[]; + /** The last finished job; the next request refines it. */ + parent_job_id: string | null; + /** The composer's text, kept for a retry. */ + draft: string; + /** Reason chips ticked on the undo prompt. */ + reasons: string[]; + /** The last state change, for the live region. */ + announcement: string; + sequence: number; +} + +export type MapAiEvent = + | { type: 'check' } + | { type: 'capabilities'; capabilities: MapAICapabilities } + | { + type: 'capabilities_failed'; + status: number | undefined; + message: string; + } + | { type: 'draft'; text: string } + | { type: 'model_chosen'; choice: MapAiModelChoice | null } + | { + type: 'submit'; + instruction: string; + selected: number; + snapshot: boolean; + at: number; + } + | { type: 'job_accepted'; job: MapAIJob; at: number } + | { type: 'job_updated'; job: MapAIJob } + | { type: 'submit_failed'; error: MapAiError } + | { type: 'cancel_requested' } + | { type: 'job_cancelled'; job: MapAIJob | null; at: number } + | { + type: 'job_failed'; + job: MapAIJob | null; + error: MapAiError; + at: number; + } + | { type: 'validating'; on_map?: boolean } + | { type: 'proposal'; proposal: Proposal; at: number } + | { type: 'previewed'; proposal: Proposal } + | { type: 'rows_changed'; proposal: Proposal } + | { type: 'kept'; proposal: Proposal } + | { type: 'undone'; proposal: Proposal; reasons: string[] } + | { type: 'refined'; proposal: Proposal } + | { type: 'reason_toggled'; reason: string } + | { type: 'reset' } + | { type: 'announce'; text: string }; + +export function initialState(): MapAiState { + return { + phase: { kind: 'idle' }, + capabilities: null, + choice: null, + turns: [], + parent_job_id: null, + draft: '', + reasons: [], + announcement: '', + sequence: 0, + }; +} + +/** The phase accepts a new instruction. */ +export function canCompose(phase: MapAiPhase): boolean { + return ( + phase.kind === 'composing' || + phase.kind === 'reviewing' || + phase.kind === 'applied' || + phase.kind === 'error' || + phase.kind === 'cancelled' + ); +} + +/** The capabilities say nothing can be sent, and why. */ +export function quotaBlock( + capabilities: MapAICapabilities | null, + strings: MapEditorAiStrings, +): string | null { + if (!capabilities?.enabled) return null; + if (capabilities.quota.user_remaining_today === 0) + return strings.quota_user_exhausted; + if (capabilities.quota.domain_remaining_month === 0) + return strings.quota_domain_exhausted; + return null; +} + +function proposalFor(phase: MapAiPhase): Proposal | null { + if (phase.kind === 'reviewing' || phase.kind === 'applied') + return phase.proposal; + return null; +} + +function outcomeFor(proposal: Proposal): ProposalOutcome { + switch (proposal.verdict) { + case 'blocked': + case 'nothing_accepted': + return 'not_applied'; + case 'questions': + return 'questions'; + case 'empty': + return 'empty'; + default: + return 'previewing'; + } +} + +type TurnInput = ThreadTurn extends infer T + ? T extends ThreadTurn + ? Omit + : never + : never; + +function withTurn(state: MapAiState, turn: TurnInput): MapAiState { + const sequence = state.sequence + 1; + return { + ...state, + sequence, + turns: [ + ...state.turns, + { ...turn, id: `turn-${sequence}` } as ThreadTurn, + ], + }; +} + +function setOutcome( + state: MapAiState, + job_id: string, + outcome: ProposalOutcome, + proposal: Proposal, + reasons: string[] = [], +): MapAiState { + return { + ...state, + turns: state.turns.map((turn) => + turn.kind === 'proposal' && turn.job_id === job_id + ? { ...turn, outcome, proposal, reasons } + : turn, + ), + }; +} + +function replaceProposal(state: MapAiState, proposal: Proposal): MapAiState { + return { + ...state, + turns: state.turns.map((turn) => + turn.kind === 'proposal' && turn.job_id === proposal.job.id + ? { ...turn, proposal, outcome: outcomeFor(proposal) } + : turn, + ), + }; +} + +/** Pure: the next state for an event. Side effects live in MapAiThread. */ +export function reduce( + state: MapAiState, + event: MapAiEvent, + strings: MapEditorAiStrings, +): MapAiState { + switch (event.type) { + case 'check': + return { + ...state, + phase: { kind: 'checking' }, + announcement: strings.checking, + }; + case 'capabilities': { + const capabilities = event.capabilities; + if (!capabilities.enabled) { + const reason = + capabilities.reason || strings.states.not_configured; + return { + ...state, + capabilities, + phase: { kind: 'unavailable', reason, hidden: false }, + announcement: format(strings.unavailable, { reason }), + }; + } + return { + ...state, + capabilities, + choice: offeredChoice(state.choice, capabilities), + phase: canCompose(state.phase) + ? state.phase + : { kind: 'composing' }, + announcement: '', + }; + } + case 'capabilities_failed': { + const hidden = event.status === 404; + const reason = hidden + ? strings.states.not_configured + : event.message || strings.states.unknown; + return { + ...state, + phase: { kind: 'unavailable', reason, hidden }, + announcement: hidden + ? '' + : format(strings.unavailable, { reason }), + }; + } + case 'draft': + return { ...state, draft: event.text }; + case 'model_chosen': + return { ...state, choice: event.choice }; + case 'submit': { + if (!canCompose(state.phase)) return state; + const next = withTurn(state, { + kind: 'person', + instruction: event.instruction, + selected: event.selected, + snapshot: event.snapshot, + at: event.at, + }); + return { + ...next, + draft: event.instruction, + reasons: [], + phase: { kind: 'submitting' }, + announcement: strings.sending, + }; + } + case 'job_accepted': + return { + ...state, + phase: { + kind: 'running', + job: event.job, + started_at: event.at, + cancelling: false, + }, + announcement: strings.states.generating, + }; + case 'job_updated': + if (state.phase.kind !== 'running') return state; + return { + ...state, + phase: { ...state.phase, job: event.job }, + }; + case 'submit_failed': + return { + ...withTurn(state, { + kind: 'status', + text: event.error.message, + error: event.error, + at: Date.now(), + }), + phase: { kind: 'error', error: event.error }, + announcement: event.error.message, + }; + case 'cancel_requested': + if (state.phase.kind !== 'running') return state; + return { + ...state, + phase: { ...state.phase, cancelling: true }, + announcement: strings.cancelling, + }; + case 'job_cancelled': + return { + ...withTurn(state, { + kind: 'status', + text: strings.states.cancelled, + error: null, + at: event.at, + }), + phase: { kind: 'cancelled', job: event.job }, + announcement: strings.states.cancelled, + }; + case 'job_failed': + return { + ...withTurn(state, { + kind: 'status', + text: event.error.message, + error: event.error, + at: event.at, + }), + phase: { kind: 'error', error: event.error }, + parent_job_id: event.job?.id ?? state.parent_job_id, + announcement: event.error.message, + }; + case 'validating': { + const proposal = proposalFor(state.phase); + if (!proposal) return state; + return { + ...state, + phase: { + kind: 'reviewing', + proposal, + validating: true, + on_map: event.on_map, + }, + }; + } + case 'proposal': { + const proposal = event.proposal; + const outcome = outcomeFor(proposal); + const next = withTurn(state, { + kind: 'proposal', + job_id: proposal.job.id, + proposal, + outcome, + reasons: [], + at: event.at, + }); + return { + ...next, + phase: { kind: 'reviewing', proposal, validating: false }, + parent_job_id: proposal.job.id, + announcement: proposalAnnouncement(proposal, strings), + }; + } + case 'previewed': { + const proposal = event.proposal; + return { + ...setOutcome(state, proposal.job.id, 'previewing', proposal), + phase: { kind: 'applied', proposal }, + announcement: format(strings.states.applied, { + count: proposal.accepted.length, + }), + }; + } + case 'rows_changed': { + const proposal = event.proposal; + return { + ...replaceProposal(state, proposal), + phase: { kind: 'reviewing', proposal, validating: false }, + announcement: proposalAnnouncement(proposal, strings), + }; + } + case 'kept': { + const proposal = event.proposal; + const total = proposal.rows.filter((row) => row.actionable).length; + const outcome: ProposalOutcome = + proposal.accepted.length < total ? 'partial' : 'kept'; + return { + ...setOutcome(state, proposal.job.id, outcome, proposal), + phase: { kind: 'composing' }, + parent_job_id: proposal.job.id, + reasons: [], + announcement: + outcome === 'kept' + ? strings.outcomes.kept + : format(strings.outcomes.partial, { + accepted: proposal.accepted.length, + total, + }), + }; + } + case 'undone': { + const proposal = event.proposal; + return { + ...setOutcome( + state, + proposal.job.id, + 'undone', + proposal, + event.reasons, + ), + phase: { kind: 'composing' }, + parent_job_id: proposal.job.id, + reasons: [], + announcement: strings.outcomes.undone, + }; + } + case 'refined': { + const proposal = event.proposal; + return setOutcome(state, proposal.job.id, 'refined', proposal); + } + case 'reason_toggled': { + const reasons = state.reasons.includes(event.reason) + ? state.reasons.filter((reason) => reason !== event.reason) + : [...state.reasons, event.reason]; + return { ...state, reasons }; + } + case 'reset': + return { + ...state, + phase: state.capabilities?.enabled + ? { kind: 'composing' } + : state.phase, + parent_job_id: null, + draft: '', + reasons: [], + announcement: '', + }; + case 'announce': + return { ...state, announcement: event.text }; + default: + return state; + } +} + +function proposalAnnouncement( + proposal: Proposal, + strings: MapEditorAiStrings, +): string { + switch (proposal.verdict) { + case 'empty': + return format(strings.states.empty, { + summary: proposal.summary || '-', + }); + case 'questions': + return proposal.questions[0] || strings.questions_title; + case 'blocked': + return strings.states.blocked; + case 'nothing_accepted': + return strings.states.nothing_accepted; + default: + return proposal.invalid_count + ? format(strings.states.partially_invalid, { + count: proposal.rows.filter((row) => row.actionable) + .length, + invalid: proposal.invalid_count, + }) + : format(strings.proposal_title, { + count: proposal.accepted.length, + }); + } +} diff --git a/libs/map-editor/src/lib/ai/strings.ts b/libs/map-editor/src/lib/ai/strings.ts new file mode 100644 index 00000000000..b3e2acef8c5 --- /dev/null +++ b/libs/map-editor/src/lib/ai/strings.ts @@ -0,0 +1,348 @@ +import { OpKind } from '../ops'; + +/** Words the panel shows; a host overrides any of them with its translations. */ +export interface MapEditorAiStrings { + title: string; + checking: string; + /** `{reason}` is the server's reason. */ + unavailable: string; + /** `{model}` and `{provider}`. */ + model_note: string; + /** Label of the model picker. */ + model_label: string; + /** `{count}` edits the person may still ask for today. */ + quota_note: string; + /** `{count}` edits the organisation has left this month. */ + quota_domain_note: string; + quota_user_exhausted: string; + quota_domain_exhausted: string; + composer_label: string; + composer_placeholder: string; + reply_placeholder: string; + send: string; + /** Tooltip on Send naming its keyboard shortcut. */ + send_shortcut: string; + cancel: string; + cancelling: string; + new_request: string; + retry: string; + /** `{count}` selected elements. */ + selection_chip: string; + clear_selection: string; + snapshot_attached: string; + snapshot_failed: string; + /** `{used}` and `{max}` characters. */ + chars: string; + too_long: string; + sending: string; + running: string; + /** `{seconds}` since the request was sent. */ + running_long: string; + /** `{count}` changes in the proposal. */ + proposal_title: string; + /** `{applied}` of the proposal's `{count}` changes are on the map. */ + preview_partial_title: string; + questions_title: string; + /** Shown under a question from the model. */ + questions_hint: string; + keep: string; + undo: string; + reason_prompt: string; + reasons: { + wrong_elements: string; + wrong_values: string; + too_much: string; + not_asked: string; + other: string; + }; + locked_note: string; + unticked_note: string; + /** `{n}` is the 1-based position of the change this one needs. */ + needs: string; + /** `{n}` is the 1-based position of the one change that needs this one. */ + needed_by_one: string; + /** `{list}` is the 1-based positions of the changes that need this one. */ + needed_by_many: string; + /** `{count}` elements a style rule reaches. */ + affects: string; + before: string; + after: string; + show_group: string; + hide_group: string; + /** `{count}` and `{kind}` for a collapsed group of rows. */ + group_label: string; + categories: { + added: string; + changed: string; + removed: string; + question: string; + none: string; + }; + /** One word per operation kind, shown beside the glyph. */ + kinds: Record; + /** Row text per operation kind; `{who}` is the element, see `describeOp`. */ + describe: { + set_attr: string; + remove_attr: string; + set_style_rule: string; + style_declaration: string; + set_text: string; + move: string; + scale: string; + rotate: string; + transform_many: string; + delete: string; + delete_many: string; + duplicate: string; + duplicate_unnamed: string; + duplicate_many: string; + insert_shape: string; + insert_shape_unnamed: string; + set_id: string; + remove_id: string; + reorder_first: string; + reorder_last: string; + reorder_before: string; + reorder_after: string; + group: string; + group_unnamed: string; + ungroup: string; + replace_element: string; + replace_path: string; + no_change: string; + ask: string; + elements: string; + element: string; + map_root: string; + }; + outcomes: { + previewing: string; + not_applied: string; + kept: string; + /** `{accepted}` of `{total}` rows kept. */ + partial: string; + undone: string; + failed: string; + cancelled: string; + empty: string; + questions: string; + }; + states: { + not_configured: string; + model_cannot: string; + quota: string; + busy: string; + generating: string; + refused: string; + timeout: string; + vendor: string; + invalid: string; + /** `{count}` changes, `{invalid}` of them locked. */ + partially_invalid: string; + /** `{summary}` is the model's own sentence. */ + empty: string; + map_changed: string; + cancelled: string; + /** `{count}` changes previewed on the map. */ + applied: string; + nothing_accepted: string; + blocked: string; + selection_refused: string; + forbidden: string; + unknown: string; + }; +} + +export const DEFAULT_AI_STRINGS: MapEditorAiStrings = { + title: 'Edit with AI', + checking: 'Checking what the AI can do here...', + unavailable: 'AI editing is not available: {reason}', + model_note: 'Edits by {model}, via {provider}', + model_label: 'Model', + quota_note: '{count} edits left today', + quota_domain_note: '{count} left this month for the organisation', + quota_user_exhausted: "You have used today's allowance of AI edits.", + quota_domain_exhausted: + "This organisation has used this month's allowance of AI edits.", + composer_label: 'Describe the change', + composer_placeholder: + 'Describe the change, for example "recolour Meeting 1.01 light blue" or "add six desks along the north wall labelled 1.031 to 1.036"', + reply_placeholder: 'Answer the question, or change the request', + send: 'Send', + send_shortcut: 'Send (Ctrl or ⌘ + Enter)', + cancel: 'Cancel', + cancelling: 'Stopping...', + new_request: 'New request', + retry: 'Retry', + selection_chip: '{count} selected', + clear_selection: 'Clear the selection', + snapshot_attached: 'Map picture attached', + snapshot_failed: + 'The map picture could not be prepared or uploaded. Please retry.', + chars: '{used} / {max}', + too_long: 'The request is too long.', + sending: 'Sending...', + running: 'Reading the map and working out the changes...', + running_long: 'Still working after {seconds} s', + proposal_title: '{count} changes', + preview_partial_title: '{applied} of {count} changes', + questions_title: 'The AI has a question', + questions_hint: + 'Click the element on the map to select it, then answer below; the selection is sent with your reply.', + keep: 'Keep', + undo: 'Undo', + reason_prompt: 'What was wrong?', + reasons: { + wrong_elements: 'Wrong elements', + wrong_values: 'Wrong values', + too_much: 'Too much', + not_asked: 'Not what I asked', + other: 'Other', + }, + locked_note: 'Cannot be applied', + unticked_note: 'Unticked; tick to apply it', + needs: 'needs change {n}', + needed_by_one: 'change {n} needs this', + needed_by_many: 'changes {list} need this', + affects: 'affects {count} elements', + before: 'Before', + after: 'After', + show_group: 'Show', + hide_group: 'Hide', + group_label: '{count} {kind}', + categories: { + added: 'Added', + changed: 'Changed', + removed: 'Removed', + question: 'Question', + none: 'No change', + }, + kinds: { + set_attr: 'Change', + set_style_rule: 'Rule', + set_text: 'Text', + transform: 'Move', + delete: 'Delete', + duplicate: 'Duplicate', + insert_shape: 'Add', + set_id: 'Rename', + reorder: 'Reorder', + group: 'Group', + ungroup: 'Ungroup', + replace_element: 'Replace', + replace_path: 'Redraw', + no_change: 'No change', + ask: 'Question', + }, + describe: { + set_attr: 'Change {name} of {who} to {value}', + remove_attr: 'Remove {name} from {who}', + set_style_rule: 'Rule {selector} {changes}, {affects}', + style_declaration: '{name} {from} -> {to}', + set_text: 'Change the text of {who} to "{text}"', + move: 'Move {who} by {x}, {y}', + scale: 'Scale {who} by {x}, {y}', + rotate: 'Rotate {who} by {degrees} degrees', + transform_many: 'Transform {who}', + delete: 'Delete {who}', + delete_many: 'Delete {who}', + duplicate: 'Duplicate {who} as {new_id}', + duplicate_unnamed: 'Duplicate {who}', + duplicate_many: 'Duplicate {who} {count} times as {pattern}', + insert_shape: 'Add {kind} {new_id} in {parent}', + insert_shape_unnamed: 'Add {kind} in {parent}', + set_id: 'Rename {who} to {new_id}', + remove_id: 'Remove the id of {who}', + reorder_first: 'Send {who} to the back', + reorder_last: 'Bring {who} to the front', + reorder_before: 'Move {who} behind {other}', + reorder_after: 'Move {who} in front of {other}', + group: 'Group {who} as {new_id}', + group_unnamed: 'Group {who}', + ungroup: 'Ungroup {who}', + replace_element: 'Replace {who} with new markup', + replace_path: 'Redraw the outline of {who}', + no_change: 'No change: {reason}', + ask: '{question}', + elements: '{count} elements', + element: '{count} element', + map_root: 'the map', + }, + outcomes: { + previewing: 'Previewed on the map', + not_applied: 'Not applied to the map', + kept: 'Kept', + partial: 'Kept {accepted} of {total}', + undone: 'Undone', + failed: 'Failed', + cancelled: 'Cancelled', + empty: 'No changes', + questions: 'Waiting for your answer', + }, + states: { + not_configured: 'No AI provider is configured for this organisation.', + model_cannot: 'The configured AI model cannot edit maps.', + quota: 'The AI edit allowance has been used up.', + busy: 'The AI is busy right now. Try again in a moment.', + generating: 'Reading the map and working out the changes...', + refused: + 'The AI provider refused this request. Rewording usually clears it.', + timeout: 'The AI took too long and the request was stopped.', + vendor: 'The AI provider returned an error.', + invalid: "The AI's answer could not be turned into map changes.", + partially_invalid: '{count} changes, {invalid} cannot be applied', + empty: 'No changes proposed. The AI said: {summary}', + map_changed: + 'The map changed since this was proposed; changes whose elements are gone are unticked.', + cancelled: 'Cancelled.', + applied: 'Applied {count} changes to the map. Keep them, or undo.', + nothing_accepted: 'Nothing is ticked, so nothing has been applied.', + blocked: 'The changes cannot be applied together.', + selection_refused: + 'That selection cannot be applied: {reason}. Your previous preview is unchanged.', + forbidden: 'You cannot change this map.', + unknown: 'Something went wrong.', + }, +}; + +/** `{key}` placeholders replaced from `values`; unknown keys stay as they are. */ +export function format( + template: string, + values: Record, +): string { + return template.replace(/\{([a-z_]+)\}/g, (match, key: string) => { + const value = values[key]; + return value === undefined || value === null ? match : String(value); + }); +} + +function mergeRecord>( + base: T, + override: Partial | undefined, +): T { + return override ? { ...base, ...override } : base; +} + +export type MapEditorAiStringsOverride = { + [K in keyof MapEditorAiStrings]?: MapEditorAiStrings[K] extends string + ? string + : Partial; +}; + +/** The defaults with the host's overrides laid over them, one level deep. */ +export function resolveAiStrings( + override: MapEditorAiStringsOverride | null | undefined, +): MapEditorAiStrings { + if (!override) return DEFAULT_AI_STRINGS; + const base = DEFAULT_AI_STRINGS; + return { + ...base, + ...(override as Partial), + reasons: mergeRecord(base.reasons, override.reasons), + categories: mergeRecord(base.categories, override.categories), + kinds: mergeRecord(base.kinds, override.kinds), + describe: mergeRecord(base.describe, override.describe), + outcomes: mergeRecord(base.outcomes, override.outcomes), + states: mergeRecord(base.states, override.states), + }; +} diff --git a/libs/map-editor/src/lib/ai/thread.ts b/libs/map-editor/src/lib/ai/thread.ts new file mode 100644 index 00000000000..f3d9cbfb1fb --- /dev/null +++ b/libs/map-editor/src/lib/ai/thread.ts @@ -0,0 +1,879 @@ +import { computed, signal, Signal, WritableSignal } from '@angular/core'; +import { MapIndex } from '../map-index'; +import { OpKind } from '../ops'; +import { annotationCandidates } from './annotations'; +import { + cancelMapAIJob, + errorMessage, + errorStatus, + isFinalJob, + mapAICapabilities, + MapAiTransport, + PLACEOS_TRANSPORT, + resolveMapAIJob, + Sleep, + submitMapAIEdit, + WatchAbortedError, + watchJob, + WatchTimeoutError, +} from './api'; +import { buildContext, mentionedEntries } from './context'; +import { unionBox } from './preview'; +import { + buildProposal, + decisionFor, + heldAfter, + Proposal, + ProposalBase, + ProposalGroup, + ProposalRow, + resolvePayload, + toggleGroup, + toggleRow, +} from './proposal'; +import { MapEditorSessionLike } from './session-contract'; +import { renderSnapshot, SnapshotSize } from './snapshot'; +import { + canCompose, + effectiveChoice, + initialState, + MapAiError, + MapAiEvent, + MapAiModelChoice, + MapAiModelOption, + MapAiState, + modelOptions, + quotaBlock, + reduce, +} from './state'; +import { + format, + MapEditorAiStrings, + MapEditorAiStringsOverride, + resolveAiStrings, +} from './strings'; +import { MapAIAnnotation, MapAIJob, MapAIResolveDecision } from './types'; + +/** Stores a file and answers with its upload id. */ +export type UploadPort = (blob: Blob, name: string) => Promise; + +/** Rooms and zones cover at least this share of the map's area to get a tag on the snapshot. */ +const TAG_MIN_AREA_SHARE = 0.002; + +/** Anchors of the large bookables and zones plus everything the instruction names, for the snapshot tags. */ +export function snapshotTags(index: MapIndex, instruction: string): string[] { + const { width, height } = index.view_box; + const floor = width * height * TAG_MIN_AREA_SHARE; + const large = index.entries.filter( + (entry) => + (entry.kind === 'bookable' || entry.kind === 'zone') && + !!entry.id && + !!entry.bbox && + entry.bbox[2] * entry.bbox[3] >= floor, + ); + const named = mentionedEntries(index, instruction); + return [...new Set([...large, ...named].map((entry) => entry.anchor))]; +} + +export type SnapshotPort = ( + session: MapEditorSessionLike, + selection: readonly string[], + tags: readonly string[], + annotations: readonly MapAIAnnotation[], +) => Promise<{ blob: Blob } & SnapshotSize>; + +/** Where the model choice is kept between visits; null keeps nothing. */ +export interface ModelMemory { + read(): MapAiModelChoice | null; + write(choice: MapAiModelChoice | null): void; +} + +export const MODEL_MEMORY_KEY = 'placeos.map-editor.ai-model'; + +/** The choice in this browser's localStorage; a store that fails reads as empty. */ +export function localModelMemory( + storage: () => Storage | undefined = () => globalThis.localStorage, +): ModelMemory { + return { + read: () => { + try { + const raw = storage()?.getItem(MODEL_MEMORY_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + return typeof parsed.provider_id === 'string' && + typeof parsed.model === 'string' + ? { provider_id: parsed.provider_id, model: parsed.model } + : null; + } catch { + return null; + } + }, + write: (choice) => { + try { + const store = storage(); + if (!store) return; + if (choice) + store.setItem(MODEL_MEMORY_KEY, JSON.stringify(choice)); + else store.removeItem(MODEL_MEMORY_KEY); + } catch { + return; + } + }, + }; +} + +export interface MapAiThreadOptions { + transport?: MapAiTransport; + upload?: UploadPort | null; + strings?: MapEditorAiStringsOverride | null; + now?: () => number; + sleep?: Sleep; + long_side?: number; + snapshot?: SnapshotPort; + idempotency_key?: () => string; + /** Defaults to this browser's localStorage. */ + memory?: ModelMemory | null; +} + +const BUSY_MESSAGE = /busy|running right now|try again/i; + +/** Ops whose row points at the elements they create once previewed. */ +const CREATES: ReadonlySet = new Set([ + 'insert_shape', + 'duplicate', + 'group', +]); + +/** The person-facing error for a failed request, by HTTP status. */ +export function errorFromStatus( + status: number | undefined, + message: string, + strings: MapEditorAiStrings, +): MapAiError { + const states = strings.states; + switch (status) { + case 503: + return BUSY_MESSAGE.test(message) + ? { kind: 'busy', message: states.busy, retryable: true } + : { + kind: 'not_configured', + message: message || states.not_configured, + retryable: false, + }; + case 429: + return { + kind: 'quota', + message: message || states.quota, + retryable: false, + }; + case 403: + return { + kind: 'forbidden', + message: states.forbidden, + retryable: false, + }; + case 404: + case 409: + case 422: + return { + kind: 'request', + message: message || states.unknown, + retryable: false, + }; + case 502: + return { + kind: 'vendor', + message: message || states.vendor, + retryable: true, + }; + case 504: + return { + kind: 'timeout', + message: states.timeout, + retryable: true, + }; + case undefined: + return { + kind: 'network', + message: message || states.unknown, + retryable: true, + }; + default: + return { + kind: 'unknown', + message: message || states.unknown, + retryable: status >= 500, + }; + } +} + +/** The person-facing error for a job the runner failed. */ +export function errorFromJob( + job: MapAIJob, + strings: MapEditorAiStrings, +): MapAiError { + const states = strings.states; + switch (job.error_kind) { + case 'moderation': + return { + kind: 'refused', + message: states.refused, + retryable: false, + job, + }; + case 'timeout': + return { + kind: 'timeout', + message: states.timeout, + retryable: true, + job, + }; + case 'vendor': + return { + kind: 'vendor', + message: job.error_message || states.vendor, + retryable: true, + job, + }; + case 'invalid_response': + return { + kind: 'invalid', + message: states.invalid, + retryable: true, + job, + }; + default: + return { + kind: 'unknown', + message: job.error_message || states.unknown, + retryable: true, + job, + }; + } +} + +function randomKey(): string { + const crypto_api = globalThis.crypto; + if (crypto_api && typeof crypto_api.randomUUID === 'function') + return crypto_api.randomUUID(); + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +function proposalOf(state: MapAiState): Proposal | null { + const phase = state.phase; + return phase.kind === 'reviewing' || phase.kind === 'applied' + ? phase.proposal + : null; +} + +/** + * One editing thread against one map: capabilities, the request, the job + * watch, the proposal preview on the live document, keep or undo, and the + * resolve record. State changes go through the reducer; this class owns the + * side effects. + */ +export class MapAiThread { + public readonly strings: MapEditorAiStrings; + public readonly state: Signal; + public readonly phase: Signal; + public readonly capabilities: Signal; + public readonly turns: Signal; + public readonly proposal: Signal; + public readonly selection_error = signal(''); + public readonly busy: Signal; + public readonly quota_block: Signal; + public readonly can_send: Signal; + public readonly announcement: Signal; + /** The model the next request asks for. */ + public readonly choice: Signal; + public readonly model_options: Signal; + + private readonly _state: WritableSignal; + private readonly _memory: ModelMemory | null; + private readonly _transport: MapAiTransport; + private readonly _upload: UploadPort | null; + private readonly _now: () => number; + private readonly _sleep: Sleep | undefined; + private readonly _snapshot: SnapshotPort; + private readonly _key: () => string; + private _abort: AbortController | null = null; + private _queue: Promise = Promise.resolve(); + /** Session version and undo depth right after the preview went on the map; -1 when none is tracked. */ + private _preview_version = -1; + private _preview_position = 0; + /** The preview changed the map, so taking it off means one undo. */ + private _preview_on_map = false; + /** The map as it was just before the preview went on, for re-validating a changed selection. */ + private _preview_base: ProposalBase | null = null; + + constructor( + private readonly _session: MapEditorSessionLike, + private readonly _zone_id: string, + options: MapAiThreadOptions = {}, + ) { + this.strings = resolveAiStrings(options.strings); + this._transport = options.transport ?? PLACEOS_TRANSPORT; + this._upload = options.upload ?? null; + this._now = options.now ?? Date.now; + this._sleep = options.sleep; + this._key = options.idempotency_key ?? randomKey; + const long_side = options.long_side; + this._snapshot = + options.snapshot ?? + ((session, selection, tags, annotations) => + renderSnapshot(session.doc, { + selection, + tags, + annotations, + long_side, + })); + this._memory = + options.memory === undefined ? localModelMemory() : options.memory; + this._state = signal({ + ...initialState(), + choice: this._memory?.read() ?? null, + }); + this.state = this._state.asReadonly(); + this.choice = computed(() => effectiveChoice(this._state())); + this.model_options = computed(() => + modelOptions(this._state().capabilities), + ); + this.phase = computed(() => this._state().phase); + this.capabilities = computed(() => this._state().capabilities); + this.turns = computed(() => this._state().turns); + this.proposal = computed(() => proposalOf(this._state())); + this.announcement = computed(() => this._state().announcement); + this.busy = computed(() => { + const phase = this._state().phase; + return ( + phase.kind === 'checking' || + phase.kind === 'submitting' || + phase.kind === 'running' || + (phase.kind === 'reviewing' && phase.validating) + ); + }); + this.quota_block = computed(() => + quotaBlock(this._state().capabilities, this.strings), + ); + this.can_send = computed( + () => canCompose(this._state().phase) && !this.quota_block(), + ); + } + + public get zone_id(): string { + return this._zone_id; + } + + public dispatch(event: MapAiEvent): void { + this._state.update((state) => reduce(state, event, this.strings)); + } + + /** Reads the capabilities; a 404 or a network failure hides the panel. */ + public async load(): Promise { + this.dispatch({ type: 'check' }); + try { + const capabilities = await mapAICapabilities(this._transport); + this.dispatch({ type: 'capabilities', capabilities }); + } catch (error) { + this.dispatch({ + type: 'capabilities_failed', + status: errorStatus(error), + message: errorMessage(error), + }); + } + } + + /** Picks the model for the next requests, remembered for this browser. */ + public chooseModel(choice: MapAiModelChoice | null): void { + this.dispatch({ type: 'model_chosen', choice }); + this._memory?.write(choice); + } + + public setDraft(text: string): void { + this.dispatch({ type: 'draft', text }); + } + + public toggleReason(reason: string): void { + this.dispatch({ type: 'reason_toggled', reason }); + } + + public hoverRow(row: ProposalRow | null): void { + this._session.setHover(row?.anchors[0] ?? null); + } + + public selectRow(row: ProposalRow): void { + if (row.anchors.length) this._session.select(row.anchors); + } + + /** + * Sends an instruction. A proposal still previewed on the map is left in + * place and resolved as refined, so the next turn builds on it. + */ + public async submit(instruction: string): Promise { + const text = instruction.trim(); + if (!text) return; + const state = this._state(); + if (!canCompose(state.phase) || this.quota_block()) return; + const limit = state.capabilities?.limits.max_instruction_chars; + if (limit && text.length > limit) { + this.dispatch({ type: 'announce', text: this.strings.too_long }); + return; + } + if (state.phase.kind === 'applied') { + await this._resolveQuietly(state.phase.proposal, 'refined'); + this.dispatch({ type: 'refined', proposal: state.phase.proposal }); + } + this.selection_error.set(''); + const selection = [...this._session.selection()]; + const annotations = structuredClone([ + ...(this._session.annotations?.() || []), + ]).map((mark) => ({ + ...mark, + hits: annotationCandidates(this._session.index(), mark), + })); + const want_snapshot = !!this._upload; + this.dispatch({ + type: 'submit', + instruction: text, + selected: selection.length, + snapshot: want_snapshot, + at: this._now(), + }); + let markup_upload_id: string | null = null; + let size: SnapshotSize | null = null; + if (want_snapshot && this._upload) { + try { + const snapshot = await this._snapshot( + this._session, + selection, + snapshotTags(this._session.index(), text), + annotations, + ); + markup_upload_id = await this._upload( + snapshot.blob, + 'map-snapshot.png', + ); + size = { + image_px: snapshot.image_px, + units_per_px: snapshot.units_per_px, + view_box: snapshot.view_box, + }; + } catch { + this.dispatch({ + type: 'submit_failed', + error: errorFromStatus( + undefined, + this.strings.snapshot_failed, + this.strings, + ), + }); + return; + } + } + const built = buildContext( + { doc: this._session.doc, index: () => this._session.index() }, + { + selection, + instruction: text, + annotations, + max_bytes: state.capabilities?.limits.max_context_bytes, + snapshot: size, + }, + ); + const choice = state.choice; + let job: MapAIJob; + try { + job = await submitMapAIEdit( + { + zone_id: this._zone_id, + instruction: text, + context: built.context, + markup_upload_id, + parent_job_id: state.parent_job_id, + idempotency_key: this._key(), + ...(choice + ? { + provider_id: choice.provider_id, + model: choice.model, + } + : {}), + }, + this._transport, + ); + } catch (error) { + const failure = errorFromStatus( + errorStatus(error), + errorMessage(error), + this.strings, + ); + this.dispatch({ type: 'submit_failed', error: failure }); + if (failure.kind === 'quota') void this._refreshQuota(); + return; + } + this.dispatch({ type: 'job_accepted', job, at: this._now() }); + void this._refreshQuota(); + if (isFinalJob(job)) { + await this._finish(job); + return; + } + const abort = new AbortController(); + this._abort = abort; + let final: MapAIJob; + try { + final = await watchJob(job.id, { + transport: this._transport, + signal: abort.signal, + since: job.version, + on_update: (update) => + this.dispatch({ type: 'job_updated', job: update }), + sleep: this._sleep, + now: this._now, + }); + } catch (error) { + if (this._abort !== abort) return; + this._abort = null; + if (error instanceof WatchAbortedError) return; + const failure: MapAiError = + error instanceof WatchTimeoutError + ? { + kind: 'timeout', + message: this.strings.states.timeout, + retryable: true, + job, + } + : errorFromStatus( + errorStatus(error), + errorMessage(error), + this.strings, + ); + this.dispatch({ + type: 'job_failed', + job, + error: failure, + at: this._now(), + }); + return; + } + if (this._abort !== abort) return; + this._abort = null; + await this._finish(final); + } + + /** Sends the last instruction again after a failure or a cancel. */ + public retry(): Promise { + const state = this._state(); + if (state.phase.kind !== 'error' && state.phase.kind !== 'cancelled') + return Promise.resolve(); + const last = [...state.turns] + .reverse() + .find((turn) => turn.kind === 'person'); + return this.submit(last?.kind === 'person' ? last.instruction : ''); + } + + /** Asks the server to stop the running job; the watch reports the outcome. */ + public async cancel(): Promise { + const phase = this._state().phase; + if (phase.kind !== 'running') return; + this.dispatch({ type: 'cancel_requested' }); + try { + const job = await cancelMapAIJob(phase.job.id, this._transport); + if (isFinalJob(job) && job.state === 'cancelled') { + this._abort?.abort(); + this._abort = null; + this.dispatch({ + type: 'job_cancelled', + job, + at: this._now(), + }); + } + } catch { + // the watch answers with the job's final state + } + } + + /** Ticks or unticks a row, then previews the new accepted set. */ + public toggle(index: number, checked: boolean): Promise { + return this._enqueue(() => + this._retick( + (proposal) => toggleRow(proposal, index, checked), + checked ? [] : [index], + ), + ); + } + + public toggleGroup(group: ProposalGroup, checked: boolean): Promise { + return this._enqueue(() => + this._retick( + (proposal) => toggleGroup(proposal, group, checked), + checked ? [] : group.indices, + ), + ); + } + + /** + * Settles the previewed proposal after the map changed outside the panel: + * undone when the preview was undone or the map is back to its saved + * state, kept when edits went on top of it. + */ + public mapChanged(): void { + const phase = this._state().phase; + if (phase.kind !== 'applied' || this._preview_version < 0) return; + if (this._session.version() === this._preview_version) return; + this._preview_base = null; + if (!this._preview_on_map) { + this._preview_version = this._session.version(); + return; + } + const undone = + this._session.historyPosition() < this._preview_position || + !this._session.dirty(); + this._preview_version = -1; + this._preview_on_map = false; + const proposal = phase.proposal; + void this._enqueue(async () => { + const state = this._state(); + if (state.phase.kind !== 'applied') return; + if (undone) { + const reasons = [...state.reasons]; + await this._resolveQuietly(proposal, 'rejected', reasons); + this.dispatch({ type: 'undone', proposal, reasons }); + return; + } + await this._resolveQuietly(proposal, decisionFor(proposal)); + this.dispatch({ type: 'kept', proposal }); + }); + } + + /** Keeps the previewed changes and records the decision. */ + public keep(): Promise { + return this._enqueue(async () => { + const phase = this._state().phase; + if (phase.kind !== 'applied') return; + const proposal = phase.proposal; + this._preview_base = null; + this._preview_on_map = false; + await this._resolveQuietly(proposal, decisionFor(proposal)); + this.dispatch({ type: 'kept', proposal }); + }); + } + + /** Takes the preview off the map and records the rejection with its reasons. */ + public undo(reasons?: readonly string[]): Promise { + return this._enqueue(async () => { + const state = this._state(); + const proposal = proposalOf(state); + if (!proposal) return; + this._preview_version = -1; + if (state.phase.kind === 'applied') this._takePreviewOff(); + const chips = [...(reasons ?? state.reasons)]; + await this._resolveQuietly(proposal, 'rejected', chips); + this.dispatch({ type: 'undone', proposal, reasons: chips }); + }); + } + + /** Starts a new thread; a previewed proposal stays on the map as abandoned. */ + public reset(): void { + this.selection_error.set(''); + this._abort?.abort(); + this._abort = null; + const proposal = proposalOf(this._state()); + if (proposal && this._state().phase.kind === 'applied') { + void this._resolveQuietly(proposal, 'abandoned'); + } + this.dispatch({ type: 'reset' }); + } + + public dispose(): void { + this._abort?.abort(); + this._abort = null; + const proposal = proposalOf(this._state()); + if (proposal && this._state().phase.kind === 'applied') { + void this._resolveQuietly(proposal, 'abandoned'); + } + } + + private _enqueue(task: () => Promise): Promise { + const run = this._queue.then(task, task); + this._queue = run.catch(() => undefined); + return run; + } + + private async _refreshQuota(): Promise { + try { + const capabilities = await mapAICapabilities(this._transport); + const quota = capabilities?.quota; + if ( + quota?.user_remaining_today === undefined || + quota?.domain_remaining_month === undefined + ) + return; + this._state.update((state) => ({ + ...state, + capabilities: state.capabilities + ? { ...state.capabilities, quota } + : null, + })); + } catch { + return; + } + } + + private async _finish(job: MapAIJob): Promise { + if (job.state === 'cancelled') { + this.dispatch({ type: 'job_cancelled', job, at: this._now() }); + return; + } + if (job.state === 'failed') { + this.dispatch({ + type: 'job_failed', + job, + error: errorFromJob(job, this.strings), + at: this._now(), + }); + return; + } + let proposal: Proposal; + try { + proposal = await buildProposal(job, this._session, this.strings); + } catch { + this.dispatch({ + type: 'job_failed', + job, + error: { + kind: 'invalid', + message: this.strings.states.invalid, + retryable: true, + job, + }, + at: this._now(), + }); + return; + } + this.dispatch({ type: 'proposal', proposal, at: this._now() }); + this._preview(proposal); + } + + private _preview(proposal: Proposal): void { + if (proposal.verdict !== 'ready' && proposal.verdict !== 'partial') + return; + const accepted = new Set(proposal.accepted); + const skip = new Set( + proposal.rows + .filter((row) => !accepted.has(row.index)) + .map((row) => row.index), + ); + const index = this._session.index(); + const base: ProposalBase = { + doc: this._session.doc.clone(), + index: () => index, + protected: this._session.protected, + render_check: this._session.render_check, + geometry: this._session.geometry, + }; + const result = this._session.apply( + proposal.ops, + `AI: ${proposal.summary || this.strings.title}`, + { skip }, + ); + this._preview_version = this._session.version(); + this._preview_position = this._session.historyPosition(); + this._preview_on_map = result.applied.length > 0; + this._preview_base = base; + const steps = new Map(result.applied.map((step) => [step.index, step])); + const rows = proposal.rows.map((row) => { + const created = CREATES.has(row.kind) + ? this._topmost(steps.get(row.index)?.created ?? []) + : []; + return { + ...row, + anchors: created.length ? created : row.anchors, + applied: steps.has(row.index), + box_before: unionBox(index, row.anchors), + }; + }); + this.dispatch({ type: 'previewed', proposal: { ...proposal, rows } }); + } + + /** @param unticked rows the person unticked directly, which are never held */ + private async _retick( + next: (proposal: Proposal) => ReadonlySet, + unticked: readonly number[] = [], + ): Promise { + const state = this._state(); + const proposal = proposalOf(state); + if (!proposal) return; + this.selection_error.set(''); + const accepted = next(proposal); + const held = heldAfter(proposal, accepted, unticked); + this._preview_version = -1; + const base = state.phase.kind === 'applied' ? this._preview_base : null; + if (state.phase.kind === 'applied' && !base) this._takePreviewOff(); + this.dispatch({ type: 'validating', on_map: !!base }); + const rebuilt = await buildProposal( + proposal.job, + base ?? this._session, + this.strings, + { accepted, dependencies: proposal.dependencies, held }, + ); + if (rebuilt.verdict === 'blocked' && state.phase.kind === 'applied') { + const restored = await buildProposal( + proposal.job, + base ?? this._session, + this.strings, + { + accepted: new Set(proposal.accepted), + dependencies: proposal.dependencies, + held: proposal.held, + }, + ); + if (base) this._takePreviewOff(); + this.dispatch({ type: 'rows_changed', proposal: restored }); + this._preview(restored); + const reason = rebuilt.batch_issues + .filter((issue) => issue.level === 'error') + .map((issue) => issue.message) + .join('; '); + const text = format(this.strings.states.selection_refused, { + reason, + }); + this.selection_error.set(text); + this.dispatch({ type: 'announce', text }); + return; + } + if (base) this._takePreviewOff(); + this.dispatch({ type: 'rows_changed', proposal: rebuilt }); + this._preview(rebuilt); + } + + /** The anchors whose parent element is not in the list too. */ + private _topmost(anchors: readonly string[]): string[] { + const set = new Set(anchors); + const doc = this._session.doc; + return anchors.filter((anchor) => { + const parent = doc.byAnchor(anchor)?.parentElement; + return !parent || !set.has(doc.anchorOf(parent)); + }); + } + + private _takePreviewOff(): void { + if (this._preview_on_map) this._session.undo(); + this._preview_on_map = false; + this._preview_base = null; + } + + private async _resolveQuietly( + proposal: Proposal, + decision: MapAIResolveDecision, + reasons: readonly string[] = [], + ): Promise { + try { + await resolveMapAIJob( + proposal.job.id, + resolvePayload(proposal, decision, reasons), + this._transport, + ); + } catch { + // telemetry only; the map is already in the state the person chose + } + } +} diff --git a/libs/map-editor/src/lib/ai/types.ts b/libs/map-editor/src/lib/ai/types.ts new file mode 100644 index 00000000000..6022edf3c80 --- /dev/null +++ b/libs/map-editor/src/lib/ai/types.ts @@ -0,0 +1,274 @@ +import { MapOp } from '../ops'; + +/** Vendors a provider row can point at. */ +export type MapAIProviderType = 'OPENAI' | 'AZURE_OPENAI' | 'GOOGLE_VERTEX'; + +/** A provider that can edit maps, as the server describes it. */ +export interface MapAIProviderCapabilities { + id: string; + name: string; + provider: MapAIProviderType | string; + /** Model used for map edits on this provider. */ + text_model: string; + /** The models the vendor serves, `text_model` first. */ + text_models?: string[]; +} + +/** Edits left of the allowance; `null` when the feature is off. */ +export interface MapAIQuota { + user_remaining_today: number | null; + domain_remaining_month: number | null; +} + +export interface MapAILimits { + max_context_bytes: number; + max_ops: number; + max_instruction_chars: number; +} + +/** `GET /api/engine/v2/maps/ai/capabilities` */ +export interface MapAICapabilities { + enabled: boolean; + /** Why the feature is unavailable; only set when `enabled` is false. */ + reason?: string | null; + providers: MapAIProviderCapabilities[]; + default_provider_id?: string | null; + quota: MapAIQuota; + limits: MapAILimits; +} + +export type MapAIJobState = + | 'queued' + | 'running' + | 'done' + | 'failed' + | 'cancelled'; + +export type MapAIJobKind = 'map_edit' | 'generate' | 'edit'; + +/** `error_kind` values the runner writes; other strings are possible. */ +export type MapAIErrorKind = + | 'timeout' + | 'vendor' + | 'moderation' + | 'invalid_response' + | 'cancelled'; + +export type MapAIResolveDecision = + | 'applied' + | 'partial' + | 'rejected' + | 'refined' + | 'abandoned'; + +/** The record `POST /jobs/:id/resolve` leaves on the job. */ +export interface MapAIResolution { + decision: MapAIResolveDecision; + accepted_op_ids: string[]; + rejected_op_ids: string[]; + reasons: unknown; + saved_upload_id: string | null; + resolved_at: number; + resolved_by: string; +} + +export interface MapAIUsageInfo { + input_tokens?: number | null; + output_tokens?: number | null; + total_tokens?: number | null; + finish?: string | null; + attempts?: number | null; +} + +/** A map edit job as the editor sees it. */ +export interface MapAIJob { + id: string; + state: MapAIJobState; + kind: MapAIJobKind; + zone_id: string | null; + provider: string | null; + model: string | null; + parent_job_id: string | null; + /** Bumps on every change; `since` on a poll is the last value seen. */ + version: number; + instruction: string | null; + markup_upload_id: string | null; + schema_version: number | null; + summary: string | null; + /** The model's operations exactly as returned. */ + ops: MapOp[]; + /** Keyed by op id `op-N`, N the op's 1-based position in `ops`. Advisory. */ + server_issues: Record; + questions: string[]; + usage: MapAIUsageInfo | null; + resolution: MapAIResolution | null; + error_kind: MapAIErrorKind | string | null; + error_message: string | null; + cost_units: number | null; + latency_ms: number | null; + /** Unix seconds. */ + created_at: number | null; + /** Unix seconds. */ + finished_at: number | null; +} + +export const MAP_AI_CONTEXT_SCHEMA = 'placeos.map-ai-context/1'; +export const MAP_AI_MARKUP_SCHEMA = 'placeos.map-markup/1'; + +/** x, y, width, height in user units. */ +export type MapAIViewBox = [number, number, number, number]; + +/** An outlined label: the glyph paths of one or two rows inside a room, desk or zone. */ +export interface MapAILabel { + /** `glyphs` for a word outlined into paths, `text` for a text element. */ + kind: 'glyphs' | 'text'; + /** Id or anchor of the room, desk or zone the label sits in; null outside every one. */ + in: string | null; + /** Anchors of the glyph paths, left to right, top row first; the text element's anchor for `text`. */ + anchors: string[]; + bbox: MapAIViewBox; + fill: string | null; + /** The words of a text label, lines separated by newlines. */ + text: string | null; +} + +/** One line of the element index the prompt builder reads. */ +export interface MapAIContextEntry { + /** Session anchor. */ + a: string; + id: string | null; + tag: string; + /** The class list as one string, null when empty. */ + cls: string | null; + label: string | null; + /** Root-space x, y, width, height; null when unknown. */ + bbox: MapAIViewBox | null; + /** Parent anchor; null for a child of the root. */ + p: string | null; + /** Paint order; null for non-rendered elements. */ + z: number | null; + kind: string; + /** Bound to a booking record. */ + prot: boolean; + /** Content hash. */ + h: string; + /** Effective fill colour; null when none or unknown. */ + fill: string | null; + /** Id or anchor of the smallest bookable or zone whose box contains this element's box. */ + in: string | null; + /** Shared by the sibling layers of one drawn object, such as a chair's shadow, seat and outline. */ + piece: string | null; +} + +export type MapAIAnnotationKind = 'box' | 'ellipse' | 'arrow' | 'path' | 'pin'; + +/** One mark the person drew, in user units. */ +export interface MapAIAnnotation { + n: number; + kind: MapAIAnnotationKind; + colour?: string; + x?: number; + y?: number; + width?: number; + height?: number; + x2?: number; + y2?: number; + points?: [number, number][]; + text?: string; + /** Anchors hit-tested under the mark. */ + hits?: string[]; + /** Anchors near the mark. */ + near?: string[]; +} + +export interface MapAIMarkup { + schema: typeof MAP_AI_MARKUP_SCHEMA; + viewBox: MapAIViewBox; + annotations: MapAIAnnotation[]; + /** Anchors of the selected elements. */ + selected: string[]; +} + +export interface MapAIContextStats { + elements: number; + ids: number; + bytes: number; +} + +/** `context` on an edit request; the server's prompt builder reads every key. */ +export interface MapAIContext { + schema: typeof MAP_AI_CONTEXT_SCHEMA; + viewBox: MapAIViewBox; + /** Pixel size of the snapshot, when one is sent. */ + image_px: [number, number]; + units_per_px: number; + protected_ids: string[]; + duplicate_ids: string[]; + index: MapAIContextEntry[]; + /** Outlined labels found by clustering glyph paths. */ + labels: MapAILabel[]; + /** Anchors whose markup is in `subtrees`. */ + shortlist: string[]; + /** Anchor to outerHTML, without editor attributes. */ + subtrees: Record; + /** Each rule as `selector{name:value;...}`. */ + style_rules: string[]; + markup: MapAIMarkup; + stats: MapAIContextStats; +} + +/** `POST /api/engine/v2/maps/ai/edit` */ +export interface MapAIEditRequest { + zone_id: string; + instruction: string; + context: MapAIContext; + /** Upload id of the snapshot with the person's marks drawn on it. */ + markup_upload_id?: string | null; + /** The job this request refines. */ + parent_job_id?: string | null; + provider_id?: string | null; + model?: string | null; + /** A repeat with the same key returns the job already accepted. */ + idempotency_key?: string | null; +} + +/** `POST /api/engine/v2/maps/ai/jobs/:id/resolve` */ +export interface MapAIResolveRequest { + decision: MapAIResolveDecision; + accepted_op_ids: string[]; + rejected_op_ids: string[]; + reasons?: unknown; + saved_upload_id?: string | null; +} + +export interface MapAIJobShowOptions { + /** Seconds to hold the request open; the server caps it at 25. */ + wait?: number; + /** The version already seen; the request returns once the job moves past it. */ + since?: number; +} + +export interface MapAIJobQueryOptions { + /** Only the caller's own jobs. Default true. */ + mine?: boolean; + zone_id?: string; + kind?: MapAIJobKind; + limit?: number; +} + +export interface MapAIUsageOptions { + /** Unix seconds; default 30 days ago. */ + from?: number; + /** Unix seconds; default now. */ + to?: number; +} + +export interface MapAIUsageRow { + kind: string; + provider: string; + model: string; + jobs: number; + candidates: number; + images_produced: number; + cost_units: number; +} diff --git a/libs/map-editor/src/lib/apply.ts b/libs/map-editor/src/lib/apply.ts new file mode 100644 index 00000000000..e5284251603 --- /dev/null +++ b/libs/map-editor/src/lib/apply.ts @@ -0,0 +1,1023 @@ +import { + ANCHOR_ATTR, + DEFAULT_ID_PREFIX, + isAllowedAttributeName, + MAX_DUPLICATE_COUNT, + MAX_REPLACEMENT_MARKUP_BYTES, + MAX_TRANSFORM_TARGETS, + REPLACEMENT_DENIED_TAGS, + SHAPE_GEOMETRY_ATTRS, + SHAPE_KINDS, + STYLE_PROPERTY_ALLOW_LIST, + SVG_NS, + TEXT_TAGS, +} from './constants'; +import { GeometryProvider } from './geometry'; +import { + escapeRegExp, + expandIdPattern, + generateId, + highestInPattern, + idIssue, + idsIn, + referencesTo, + rewriteReferences, +} from './ids'; +import { + canvasExpansionIssue, + MapDocument, + stripEditorAttributes, + Target, +} from './map-document'; +import { + changeOfBasis, + composeTransform, + formatTransform, + invert, + isIdentity, + Matrix, + translation, +} from './matrix'; +import { + DeleteOp, + DuplicateOp, + GroupOp, + InsertShapeOp, + MapOp, + ReorderOp, + ReplaceElementOp, + ReplacePathOp, + SetAttrOp, + SetIdOp, + SetStyleRuleOp, + SetTextOp, + targetsOf, + TransformOp, + UngroupOp, +} from './ops'; +import { checkPathData } from './path-data'; +import { isUnsafeCssValue, purifyMarkup } from './sanitise'; +import { + formatDeclarations, + inlineStyleValue, + isValidSelector, +} from './style-resolution'; +import { attributeValueIssue, geometryValueIssue } from './value-syntax'; + +export interface AppliedOp { + index: number; + op: MapOp; + /** Anchors of the elements the op named. */ + anchors: string[]; + /** Anchors of elements the op created. */ + created: string[]; + /** Every anchor the op wrote to, named or not. */ + touched: string[]; + note?: string; +} + +export interface RejectedOp { + index: number; + op: MapOp; + reason: string; +} + +export interface ApplyResult { + applied: AppliedOp[]; + rejected: RejectedOp[]; + touched: string[]; +} + +export interface ApplyOptions { + geometry?: GeometryProvider; + /** Indices of ops to leave out, such as rows the person unticked. */ + skip?: ReadonlySet; +} + +type Outcome = + | { + ok: true; + anchors: string[]; + created?: string[]; + touched?: string[]; + note?: string; + } + | { ok: false; reason: string }; + +const PAINT_STYLE_ATTRS = new Set([ + 'fill', + 'stroke', + 'opacity', + 'fill-opacity', + 'stroke-opacity', +]); +const BOOKING_OVERLAY_ID = /^area-.+-status$/i; +const BOOKING_OVERLAY_FILL_OPACITY = '0.3'; + +/** Apply ops in order through the DOM API; a rejected op leaves the document as it was. */ +export function applyOps( + doc: MapDocument, + ops: readonly MapOp[], + options: ApplyOptions = {}, +): ApplyResult { + const geometry = options.geometry || doc.geometry; + const result: ApplyResult = { applied: [], rejected: [], touched: [] }; + const touched = new Set(); + ops.forEach((op, index) => { + if (options.skip?.has(index)) return; + const outcome = applyOne(doc, op, geometry); + if (outcome.ok === false) { + result.rejected.push({ index, op, reason: outcome.reason }); + return; + } + doc.markChanged(); + const op_touched = [ + ...new Set([ + ...outcome.anchors, + ...(outcome.created || []), + ...(outcome.touched || []), + ]), + ]; + for (const anchor of op_touched) touched.add(anchor); + result.applied.push({ + index, + op, + anchors: outcome.anchors, + created: outcome.created || [], + touched: op_touched, + ...(outcome.note ? { note: outcome.note } : {}), + }); + }); + result.touched = [...touched]; + return result; +} + +function applyOne( + doc: MapDocument, + op: MapOp, + geometry: GeometryProvider, +): Outcome { + switch (op.op) { + case 'set_attr': + return setAttr(doc, op); + case 'set_style_rule': + return setStyleRule(doc, op); + case 'set_text': + return setText(doc, op); + case 'transform': + return transform(doc, op, geometry); + case 'delete': + return remove(doc, op); + case 'duplicate': + return duplicate(doc, op, geometry); + case 'insert_shape': + return insertShape(doc, op, geometry); + case 'set_id': + return setId(doc, op); + case 'reorder': + return reorder(doc, op); + case 'group': + return group(doc, op); + case 'ungroup': + return ungroup(doc, op); + case 'replace_element': + return replaceElement(doc, op); + case 'replace_path': + return replacePath(doc, op); + case 'no_change': + case 'ask': + return { ok: true, anchors: [] }; + default: + return { + ok: false, + reason: `unknown op "${(op as { op: string }).op}"`, + }; + } +} + +function resolveAll( + doc: MapDocument, + targets: readonly Target[], +): { elements: Element[] } | { error: string } { + const elements: Element[] = []; + for (const target of targets) { + const resolution = doc.resolve(target); + if (resolution.ok === false) return { error: resolution.error }; + if (!elements.includes(resolution.element)) + elements.push(resolution.element); + } + return { elements }; +} + +function isRoot(doc: MapDocument, element: Element): boolean { + return element === doc.root; +} + +function prependTransform(element: Element, matrix: Matrix): void { + const existing = element.getAttribute('transform') || ''; + element.setAttribute( + 'transform', + `${formatTransform(matrix)} ${existing}`.trim(), + ); +} + +/** + * Express a root-space matrix in the element's parent space, so prepending it + * to the element's own transform moves the element as the matrix says. + */ +function toParentSpace( + element: Element, + root_space: Matrix, + geometry: GeometryProvider, + doc: MapDocument, +): { matrix: Matrix; converted: boolean } { + const parent = element.parentElement; + if (!parent || isRoot(doc, parent)) + return { matrix: root_space, converted: true }; + const parent_ctm = geometry.ctmOf(parent); + if (!parent_ctm) return { matrix: root_space, converted: false }; + if (isIdentity(parent_ctm)) return { matrix: root_space, converted: true }; + const converted = changeOfBasis(root_space, parent_ctm); + return converted + ? { matrix: converted, converted: true } + : { matrix: root_space, converted: false }; +} + +function setAttr(doc: MapDocument, op: SetAttrOp): Outcome { + const resolution = doc.resolve(op.target); + if (resolution.ok === false) return { ok: false, reason: resolution.error }; + const element = resolution.element; + const name = (op.name || '').trim(); + if (name === 'viewBox') { + if (!isRoot(doc, element)) + return { + ok: false, + reason: 'canvas bounds can only be changed on the SVG root', + }; + const reason = canvasExpansionIssue(doc.view_box, op.value); + if (reason) return { ok: false, reason }; + element.setAttribute('viewBox', op.value || ''); + return { ok: true, anchors: [doc.anchorOf(element)] }; + } + if (!isAllowedAttributeName(name)) { + return { ok: false, reason: `attribute "${name}" is not editable` }; + } + const style = (element as SVGElement).style; + if (op.value === null || op.value === undefined) { + element.removeAttribute(name); + if (PAINT_STYLE_ATTRS.has(name) && style) { + style.removeProperty(name); + if (!style.length) element.removeAttribute('style'); + } + } else { + const value = `${op.value}`; + const issue = attributeValueIssue(name, value); + if (issue) return { ok: false, reason: issue }; + element.setAttribute(name, value); + if ( + PAINT_STYLE_ATTRS.has(name) && + style && + (inlineStyleValue(element, name) || + ruleDeclares(doc, element, name)) + ) { + style.setProperty(name, value); + } + } + return { ok: true, anchors: [doc.anchorOf(element)] }; +} + +/** Whether a stylesheet rule, which would beat the attribute, sets the property on the element. */ +function ruleDeclares( + doc: MapDocument, + element: Element, + name: string, +): boolean { + for (const rule of doc.styleRules()) { + if (!(name in rule.declarations)) continue; + for (const selector of rule.selectors) { + try { + if (element.matches(selector)) return true; + } catch { + // an invalid selector matches nothing + } + } + } + return false; +} + +export function styleElementOf(doc: MapDocument): Element | null { + return doc.root.querySelector('style'); +} + +/** The rule's declarations as written, `!important` kept, overridden by the new ones. */ +function mergeDeclarations( + body: string, + declarations: Readonly>, +): string { + const merged = new Map(); + for (const part of body.split(';')) { + const index = part.indexOf(':'); + if (index < 0) continue; + const name = part.slice(0, index).trim(); + const value = part.slice(index + 1).trim(); + if (name && value) merged.set(name, value); + } + for (const [name, value] of Object.entries(declarations)) { + merged.set(name, value); + } + return [...merged].map(([name, value]) => `${name}:${value}`).join(';'); +} + +/** Merge declarations into the rule whose whole selector is this one, or append a rule. */ +export function upsertStyleRule( + css: string, + selector: string, + declarations: Readonly>, +): string { + const pattern = new RegExp( + `(^|[};])(\\s*)(${escapeRegExp(selector)})\\s*\\{([^{}]*)\\}`, + ); + const match = css.match(pattern); + if (match && match.index !== undefined) { + const block = `${match[1]}${match[2]}${selector}{${mergeDeclarations(match[4], declarations)}}`; + return ( + css.slice(0, match.index) + + block + + css.slice(match.index + match[0].length) + ); + } + const block = `${selector}{${formatDeclarations(declarations)}}`; + return css.trim() ? `${css.replace(/\s+$/, '')}\n${block}` : block; +} + +function setStyleRule(doc: MapDocument, op: SetStyleRuleOp): Outcome { + const selector = (op.selector || '').trim(); + if ( + !selector || + selector === '*' || + isUnsafeCssValue(selector) || + selector.includes(',') + ) { + return { ok: false, reason: `selector "${selector}" is not allowed` }; + } + if (!isValidSelector(doc.root, selector)) { + return { ok: false, reason: `selector "${selector}" does not parse` }; + } + if (!Array.isArray(op.declarations) || !op.declarations.length) { + return { ok: false, reason: 'no declarations' }; + } + const declarations: Record = {}; + for (const { name, value } of op.declarations) { + const property = typeof name === 'string' ? name.trim() : ''; + if (!STYLE_PROPERTY_ALLOW_LIST.has(property)) { + return { + ok: false, + reason: `property "${property}" is not editable`, + }; + } + if (typeof value !== 'string' || isUnsafeCssValue(value)) { + return { + ok: false, + reason: `value for "${property}" is not allowed`, + }; + } + const issue = attributeValueIssue(property, value); + if (issue) return { ok: false, reason: issue }; + declarations[property] = value.trim(); + } + let style = styleElementOf(doc); + const created: string[] = []; + if (!style) { + style = doc.root.ownerDocument.createElementNS(SVG_NS, 'style'); + doc.root.insertBefore(style, doc.root.firstChild); + created.push(...doc.assignAnchors(style)); + } + style.textContent = upsertStyleRule( + style.textContent || '', + selector, + declarations, + ); + return { ok: true, anchors: [doc.anchorOf(style)], created }; +} + +function setText(doc: MapDocument, op: SetTextOp): Outcome { + const resolution = doc.resolve(op.target); + if (resolution.ok === false) return { ok: false, reason: resolution.error }; + const element = resolution.element; + if (!TEXT_TAGS.has(element.localName)) { + return { + ok: false, + reason: `set_text needs a text or tspan element, not <${element.localName}>`, + }; + } + if (typeof op.text !== 'string') + return { ok: false, reason: 'text must be a string' }; + const removed = MapDocument.subtree(element) + .slice(1) + .map((line) => doc.anchorOf(line)); + const spans = writeTextLines(element, op.text); + const created = spans.flatMap((span) => doc.assignAnchors(span)); + return { + ok: true, + anchors: [doc.anchorOf(element)], + created, + touched: removed, + }; +} + +function isPoint(value: unknown): value is [number, number] { + return ( + Array.isArray(value) && + value.length === 2 && + value.every((n) => typeof n === 'number' && Number.isFinite(n)) + ); +} + +function transform( + doc: MapDocument, + op: TransformOp, + geometry: GeometryProvider, +): Outcome { + const targets = targetsOf(op); + if (!targets.length) + return { ok: false, reason: 'transform names no target' }; + if (targets.length > MAX_TRANSFORM_TARGETS) { + return { + ok: false, + reason: `transform names more than ${MAX_TRANSFORM_TARGETS} targets`, + }; + } + if (op.translate != null && !isPoint(op.translate)) { + return { ok: false, reason: 'translate must be [dx, dy]' }; + } + if ( + op.scale != null && + (!isPoint(op.scale) || !op.scale[0] || !op.scale[1]) + ) { + return { + ok: false, + reason: 'scale must be [sx, sy] with non-zero factors', + }; + } + if (op.rotate != null && !Number.isFinite(op.rotate)) { + return { ok: false, reason: 'rotate must be a number of degrees' }; + } + if (op.origin != null && !isPoint(op.origin)) { + return { ok: false, reason: 'origin must be [x, y]' }; + } + if (!op.translate && !op.scale && !op.rotate) { + return { + ok: false, + reason: 'transform has no translate, scale or rotate', + }; + } + const resolved = resolveAll(doc, targets); + if ('error' in resolved) return { ok: false, reason: resolved.error }; + if (resolved.elements.some((element) => isRoot(doc, element))) { + return { ok: false, reason: 'the root cannot be transformed' }; + } + const root_space = composeTransform(op); + const notes: string[] = []; + for (const element of resolved.elements) { + const { matrix, converted } = toParentSpace( + element, + root_space, + geometry, + doc, + ); + prependTransform(element, matrix); + if (!converted) notes.push(doc.anchorOf(element)); + } + return { + ok: true, + anchors: resolved.elements.map((element) => doc.anchorOf(element)), + ...(notes.length + ? { + note: `applied in the element's own space, parent transform unknown: ${notes.join(', ')}`, + } + : {}), + }; +} + +function remove(doc: MapDocument, op: DeleteOp): Outcome { + const targets = targetsOf(op); + if (!targets.length) return { ok: false, reason: 'delete names no target' }; + const resolved = resolveAll(doc, targets); + if ('error' in resolved) return { ok: false, reason: resolved.error }; + const style_count = doc.root.querySelectorAll('style').length; + for (const element of resolved.elements) { + if (isRoot(doc, element)) + return { ok: false, reason: 'the root cannot be deleted' }; + if (element.localName === 'defs') + return { ok: false, reason: ' cannot be deleted' }; + if (element.localName === 'style' && style_count === 1) { + return { ok: false, reason: 'the only + Boardroom + + Boardroom`, +); + +function target(doc: MapDocument, id: string): Target { + const element = doc.byId(id); + if (!element) throw new Error(`no ${id}`); + return { a: doc.anchorOf(element), id, h: doc.hashOf(element) }; +} + +function context(doc: MapDocument) { + const session = new FakeSession(doc); + return { doc, index: session.index(), strings: DEFAULT_AI_STRINGS }; +} + +describe('describeOp', () => { + const doc = MapDocument.load(MAP); + const ctx = context(doc); + const why = 'because'; + + it('names elements as id (label), id, label or tag anchor', () => { + expect(whoOf(target(doc, 'area-2.01-status'), ctx)).toBe( + 'area-2.01-status (Boardroom)', + ); + expect(whoOf(target(doc, 'desk-01.004'), ctx)).toBe('desk-01.004'); + expect(whoOf({ a: 'e9999' }, ctx)).toBe('e9999'); + const rooms = doc.byId('rooms') as Element; + const first_rect = rooms.firstElementChild as Element; + expect(whoOf({ a: doc.anchorOf(first_rect) }, ctx)).toBe( + 'area-2.01-status (Boardroom)', + ); + }); + + it('describes every op kind in plain words', () => { + const room = target(doc, 'area-2.01-status'); + const desk = target(doc, 'desk-01.004'); + const desks = ['desk-01.004', 'desk-01.005', 'desk-01.006'].map((id) => + target(doc, id), + ); + const cases: [MapOp, string][] = [ + [ + { + op: 'set_attr', + why, + target: room, + name: 'fill', + value: '#d1fae5', + }, + 'Change fill of area-2.01-status (Boardroom) to #d1fae5', + ], + [ + { + op: 'set_attr', + why, + target: room, + name: 'fill', + value: null, + }, + 'Remove fill from area-2.01-status (Boardroom)', + ], + [ + { + op: 'set_style_rule', + why, + selector: '.cls-12', + declarations: [{ name: 'fill', value: '#eee' }], + }, + 'Rule .cls-12 fill #fff -> #eee, affects 2 elements', + ], + [ + { + op: 'set_text', + why, + target: target(doc, 'label-1'), + text: 'Board room', + }, + 'Change the text of label-1 (Boardroom) to "Board room"', + ], + [ + { op: 'transform', why, targets: desks, translate: [120, 0] }, + 'Move 3 elements by 120, 0', + ], + [ + { op: 'transform', why, target: desk, translate: [12.345, -1] }, + 'Move desk-01.004 by 12.35, -1', + ], + [ + { op: 'transform', why, target: desk, scale: [2, 2] }, + 'Scale desk-01.004 by 2, 2', + ], + [ + { op: 'transform', why, target: desk, rotate: 90 }, + 'Rotate desk-01.004 by 90 degrees', + ], + [ + { + op: 'transform', + why, + target: desk, + translate: [1, 1], + rotate: 90, + }, + 'Transform desk-01.004', + ], + [{ op: 'delete', why, target: desk }, 'Delete desk-01.004'], + [ + { op: 'delete', why, targets: desks.slice(0, 2) }, + 'Delete 2 elements', + ], + [ + { + op: 'duplicate', + why, + target: desk, + new_id: 'desk-01.007', + translate: [40, 0], + }, + 'Duplicate desk-01.004 as desk-01.007', + ], + [ + { op: 'duplicate', why, target: desk, translate: [40, 0] }, + 'Duplicate desk-01.004', + ], + [ + { + op: 'duplicate', + why, + target: desk, + translate: [40, 0], + count: 3, + id_pattern: 'desk-01.0NN', + }, + 'Duplicate desk-01.004 3 times as desk-01.0NN', + ], + [ + { + op: 'insert_shape', + why, + parent: target(doc, 'rooms'), + kind: 'rect', + attrs: [], + new_id: 'ai-1', + }, + 'Add rect ai-1 in rooms', + ], + [ + { op: 'insert_shape', why, kind: 'circle', attrs: [] }, + 'Add circle in the map', + ], + [ + { op: 'set_id', why, target: desk, new_id: 'desk-01.010' }, + 'Rename desk-01.004 to desk-01.010', + ], + [ + { op: 'set_id', why, target: desk, new_id: null }, + 'Remove the id of desk-01.004', + ], + [ + { op: 'reorder', why, target: desk, position: 'last' }, + 'Bring desk-01.004 to the front', + ], + [ + { op: 'reorder', why, target: desk, position: 'first' }, + 'Send desk-01.004 to the back', + ], + [ + { + op: 'reorder', + why, + target: desk, + before: target(doc, 'desk-01.005'), + }, + 'Move desk-01.004 behind desk-01.005', + ], + [ + { + op: 'reorder', + why, + target: desk, + after: target(doc, 'desk-01.005'), + }, + 'Move desk-01.004 in front of desk-01.005', + ], + [ + { op: 'group', why, targets: desks.slice(0, 2), new_id: 'g1' }, + 'Group 2 elements as g1', + ], + [{ op: 'group', why, targets: [desk] }, 'Group desk-01.004'], + [ + { op: 'ungroup', why, target: target(doc, 'desks') }, + 'Ungroup desks', + ], + [ + { op: 'replace_element', why, target: desk, markup: '' }, + 'Replace desk-01.004 with new markup', + ], + [ + { + op: 'replace_path', + why, + target: target(doc, 'area-2.02-status'), + d: 'M0 0', + }, + 'Redraw the outline of area-2.02-status', + ], + [ + { op: 'no_change', why, reason: 'already blue' }, + 'No change: already blue', + ], + [{ op: 'ask', why, question: 'Which room?' }, 'Which room?'], + ]; + for (const [op, expected] of cases) { + expect(describeOp(op, ctx)).toBe(expected); + } + }); + + it('sorts ops into added, changed, removed, question and none', () => { + expect( + opCategory({ op: 'insert_shape', why, kind: 'rect', attrs: [] }), + ).toBe('added'); + expect(opCategory({ op: 'delete', why, target: { id: 'x' } })).toBe( + 'removed', + ); + expect(opCategory({ op: 'ask', why, question: '?' })).toBe('question'); + expect(opCategory({ op: 'no_change', why, reason: '-' })).toBe('none'); + expect( + opCategory({ op: 'set_text', why, target: { id: 'x' }, text: '' }), + ).toBe('changed'); + expect( + opCategory({ + op: 'group', + why, + targets: [{ id: 'x' }, { id: 'y' }], + }), + ).toBe('changed'); + expect(opGlyph('added')).toBe('+'); + expect(opGlyph('removed')).toBe('−'); + }); +}); + +describe('proposal', () => { + function officeDoc(): MapDocument { + return MapDocument.load(MAP); + } + + it('writes op ids and the server reasons onto the ops', () => { + const ops = opsWithServerIssues({ + ops: [ + { op: 'no_change', why: '-', reason: 'a' }, + { op: 'ask', why: '-', question: 'b' }, + ], + server_issues: { 'op-2': 'doubtful' }, + }); + expect(ops[0]).toEqual({ + op: 'no_change', + why: '-', + reason: 'a', + id: 'op-1', + }); + expect(ops[1]).toMatchObject({ id: 'op-2', server_issue: 'doubtful' }); + }); + + it('derives dependencies from elements earlier ops create', () => { + const doc = officeDoc(); + const rooms = target(doc, 'rooms'); + const desk = target(doc, 'desk-01.004'); + const ops: MapOp[] = [ + { + op: 'insert_shape', + why: '-', + parent: rooms, + kind: 'rect', + attrs: [ + { name: 'x', value: '300' }, + { name: 'y', value: '10' }, + { name: 'width', value: '50' }, + { name: 'height', value: '50' }, + ], + new_id: 'ai-1', + }, + { + op: 'set_attr', + why: '-', + target: { id: 'ai-1' }, + name: 'fill', + value: '#abc', + }, + { + op: 'duplicate', + why: '-', + target: desk, + new_id: 'desk-01.009', + translate: [0, 40], + }, + { + op: 'set_attr', + why: '-', + target: { id: 'desk-01.009' }, + name: 'fill', + value: '#abc', + }, + { + op: 'set_id', + why: '-', + target: target(doc, 'desk-01.005'), + new_id: 'desk-01.050', + }, + { + op: 'set_attr', + why: '-', + target: { id: 'desk-01.050' }, + name: 'fill', + value: '#abc', + }, + { + op: 'set_attr', + why: '-', + target: desk, + name: 'stroke', + value: '#000', + }, + ]; + expect(deriveDependencies(doc, ops)).toEqual([ + [], + [0], + [], + [2], + [], + [4], + [], + ]); + expect(referencedTargets(ops[0]).map((t) => t.id)).toEqual(['rooms']); + expect( + cascadeDependencies(new Set([1, 3, 6]), [ + [], + [0], + [], + [2], + [], + [4], + [], + ]), + ).toEqual(new Set([6])); + expect(doc.byId('ai-1')).toBeNull(); + }); + + it('builds rows with the validator verdicts, server reasons and the trial', async () => { + const doc = officeDoc(); + const session = new FakeSession(doc); + const room = target(doc, 'area-2.01-status'); + const other = target(doc, 'area-2.02-status'); + const done = job({ + state: 'done', + summary: 'Recoloured two rooms.', + ops: [ + { + op: 'set_attr', + why: '-', + target: room, + name: 'fill', + value: '#d1fae5', + }, + { + op: 'set_attr', + why: '-', + target: { a: 'e9999' }, + name: 'fill', + value: '#d1fae5', + }, + { + op: 'set_attr', + why: '-', + target: other, + name: 'fill', + value: '#fee', + }, + ], + server_issues: { + 'op-3': 'the model may have meant the other room', + }, + }); + const proposal = await buildProposal(done, session, DEFAULT_AI_STRINGS); + expect(proposal.summary).toBe('Recoloured two rooms.'); + expect(proposal.rows.map((row) => row.id)).toEqual([ + 'op-1', + 'op-2', + 'op-3', + ]); + expect(proposal.rows[0]).toMatchObject({ + checked: true, + applied: true, + locked: false, + category: 'changed', + glyph: '~', + kind_word: 'Change', + text: 'Change fill of area-2.01-status (Boardroom) to #d1fae5', + anchors: [room.a], + }); + expect(proposal.rows[1].locked).toBe(true); + expect(proposal.rows[1].checked).toBe(false); + expect(proposal.rows[1].applied).toBe(false); + expect(proposal.rows[1].issues.map((issue) => issue.code)).toContain( + 'unknown_target', + ); + expect(proposal.rows[2]).toMatchObject({ + checked: false, + locked: false, + applied: false, + server_issue: 'the model may have meant the other room', + }); + expect(proposal.rows[2].issues.map((issue) => issue.code)).toEqual([ + 'server_issue', + ]); + expect(proposal.accepted).toEqual([0]); + expect(proposal.invalid_count).toBe(1); + expect(proposal.verdict).toBe('partial'); + expect(proposal.blocking).toBe(false); + expect(proposal.trial_markup).toContain('#d1fae5'); + expect(proposal.trial_markup).not.toContain('#fee'); + expect(proposal.before_markup).not.toContain('#d1fae5'); + expect(proposal.groups).toBeNull(); + expect(doc.byId('area-2.01-status')?.getAttribute('fill')).toBeNull(); + + const reticked = await buildProposal( + done, + session, + DEFAULT_AI_STRINGS, + { + accepted: new Set([0, 1, 2]), + dependencies: proposal.dependencies, + }, + ); + expect(reticked.accepted).toEqual([0, 2]); + expect(reticked.rows[2].checked).toBe(true); + expect(reticked.rows[2].applied).toBe(true); + expect(reticked.trial_markup).toContain('#fee'); + expect(reticked.verdict).toBe('partial'); + expect(decisionFor(reticked)).toBe('partial'); + }); + + it('ticks parents with a child and unticks children with a parent', async () => { + const doc = officeDoc(); + const session = new FakeSession(doc); + const done = job({ + state: 'done', + ops: [ + { + op: 'insert_shape', + why: '-', + parent: target(doc, 'rooms'), + kind: 'rect', + attrs: [ + { name: 'x', value: '300' }, + { name: 'y', value: '10' }, + { name: 'width', value: '50' }, + { name: 'height', value: '50' }, + ], + new_id: 'ai-1', + }, + { + op: 'set_attr', + why: '-', + target: { id: 'ai-1' }, + name: 'fill', + value: '#abc', + }, + { + op: 'set_attr', + why: '-', + target: target(doc, 'desk-01.004'), + name: 'fill', + value: '#abc', + }, + ], + }); + const proposal = await buildProposal(done, session, DEFAULT_AI_STRINGS); + expect(proposal.dependencies).toEqual([[], [0], []]); + expect(proposal.rows[1].depends_on).toEqual([0]); + expect(proposal.rows[0].dependents).toEqual([1]); + expect(proposal.accepted).toEqual([0, 1, 2]); + expect([...toggleRow(proposal, 0, false)].sort()).toEqual([2]); + const without_parent: Proposal = { ...proposal, accepted: [2] }; + expect([...toggleRow(without_parent, 1, true)].sort()).toEqual([ + 0, 1, 2, + ]); + expect([...toggleRow(proposal, 2, false)].sort()).toEqual([0, 1]); + expect([...toggleRow(proposal, 99, false)].sort()).toEqual([0, 1, 2]); + }); + + it('counts only the ticked rows in the preview summary', async () => { + const doc = officeDoc(); + const session = new FakeSession(doc); + const fill = (id: string): MapOp => ({ + op: 'set_attr', + why: '-', + target: target(doc, id), + name: 'fill', + value: '#abc', + }); + const done = job({ + state: 'done', + ops: [fill('desk-01.004'), fill('desk-01.005')], + }); + const all = await buildProposal(done, session, DEFAULT_AI_STRINGS); + expect(previewSummary(all, DEFAULT_AI_STRINGS)?.title).toBe( + '2 changes', + ); + const one = await buildProposal(done, session, DEFAULT_AI_STRINGS, { + accepted: new Set([1]), + }); + const summary = previewSummary(one, DEFAULT_AI_STRINGS); + expect(summary?.title).toBe('1 of 2 changes'); + expect(summary?.counts.map((entry) => entry.count)).toEqual([1]); + }); + + it('ticks the rows a cascade unticked when the row they need is ticked again', async () => { + const doc = officeDoc(); + const session = new FakeSession(doc); + const fill = (name: string): MapOp => ({ + op: 'set_attr', + why: '-', + target: { id: 'ai-1' }, + name, + value: '#abc', + }); + const done = job({ + state: 'done', + ops: [ + { + op: 'insert_shape', + why: '-', + parent: target(doc, 'rooms'), + kind: 'rect', + attrs: [ + { name: 'x', value: '300' }, + { name: 'y', value: '10' }, + { name: 'width', value: '50' }, + { name: 'height', value: '50' }, + ], + new_id: 'ai-1', + }, + fill('fill'), + fill('stroke'), + ], + }); + const step = async ( + from: Proposal, + index: number, + checked: boolean, + ): Promise => { + const accepted = toggleRow(from, index, checked); + const held = heldAfter(from, accepted, checked ? [] : [index]); + return buildProposal(done, session, DEFAULT_AI_STRINGS, { + accepted, + held, + dependencies: from.dependencies, + }); + }; + const proposal = await buildProposal(done, session, DEFAULT_AI_STRINGS); + + const parent_off = await step(proposal, 0, false); + expect(parent_off.accepted).toEqual([]); + expect(parent_off.held).toEqual([1, 2]); + const parent_on = await step(parent_off, 0, true); + expect(parent_on.accepted).toEqual([0, 1, 2]); + expect(parent_on.held).toEqual([]); + + const stroke_off = await step(proposal, 2, false); + const both_off = await step(stroke_off, 0, false); + expect(both_off.held).toEqual([1]); + expect((await step(both_off, 0, true)).accepted).toEqual([0, 1]); + }); + + it('groups rows by kind above twenty and toggles a group as a whole', async () => { + const doc = officeDoc(); + const session = new FakeSession(doc); + const desk = target(doc, 'desk-01.004'); + const ops: MapOp[] = Array.from({ length: 23 }, (_, i) => ({ + op: 'set_attr' as const, + why: '-', + target: desk, + name: `data-n${i}`, + value: String(i), + })); + ops.push({ + op: 'delete', + why: '-', + target: target(doc, 'desk-01.006'), + }); + ops.push({ op: 'ask', why: '-', question: 'Sure?' }); + const proposal = await buildProposal( + job({ state: 'done', ops }), + session, + DEFAULT_AI_STRINGS, + ); + expect( + proposal.groups?.map((group) => [ + group.key, + group.indices.length, + group.label, + ]), + ).toEqual([ + ['changed:set_attr', 23, '23 Change'], + ['removed:delete', 1, '1 Delete'], + ['question:ask', 1, '1 Question'], + ]); + expect(proposal.groups?.[0].checked).toBe('all'); + expect(proposal.groups?.[2].locked).toBe(true); + const group = proposal.groups?.[0]; + if (!group) throw new Error('no group'); + expect(toggleGroup(proposal, group, false).size).toBe(1); + const regrouped = groupRows( + proposal.rows.map((row) => + row.index === 0 ? { ...row, checked: false } : row, + ), + DEFAULT_AI_STRINGS, + ); + expect(regrouped[0].checked).toBe('some'); + }); + + it('answers a verdict for every shape of proposal', () => { + const ask: MapOp = { op: 'ask', why: '-', question: '?' }; + const change: MapOp = { op: 'delete', why: '-', target: { id: 'x' } }; + expect(verdictOf([], new Set(), false, [])).toBe('empty'); + expect( + verdictOf([{ actionable: false, op: ask }], new Set(), false, []), + ).toBe('questions'); + expect( + verdictOf( + [ + { + actionable: false, + op: { op: 'no_change', why: '-', reason: '-' }, + }, + ], + new Set(), + false, + ['why?'], + ), + ).toBe('questions'); + expect( + verdictOf( + [{ actionable: true, op: change }], + new Set([0]), + true, + [], + ), + ).toBe('blocked'); + expect( + verdictOf([{ actionable: true, op: change }], new Set(), false, []), + ).toBe('nothing_accepted'); + expect( + verdictOf( + [ + { actionable: true, op: change }, + { actionable: true, op: change }, + ], + new Set([0]), + false, + [], + ), + ).toBe('partial'); + expect( + verdictOf( + [{ actionable: true, op: change }], + new Set([0]), + false, + [], + ), + ).toBe('ready'); + }); + + it('builds the resolve payload from the rows', async () => { + const doc = officeDoc(); + const session = new FakeSession(doc); + const done = job({ + state: 'done', + ops: [ + { + op: 'set_attr', + why: '-', + target: target(doc, 'desk-01.004'), + name: 'fill', + value: '#abc', + }, + { + op: 'set_attr', + why: '-', + target: target(doc, 'desk-01.005'), + name: 'fill', + value: '#abc', + }, + { op: 'no_change', why: '-', reason: 'nothing else' }, + ], + }); + const all = await buildProposal(done, session, DEFAULT_AI_STRINGS); + expect(decisionFor(all)).toBe('applied'); + expect(resolvePayload(all, 'applied')).toEqual({ + decision: 'applied', + accepted_op_ids: ['op-1', 'op-2'], + rejected_op_ids: [], + saved_upload_id: null, + }); + expect(resolvePayload(all, 'rejected', ['too_much', 'other'])).toEqual({ + decision: 'rejected', + accepted_op_ids: [], + rejected_op_ids: ['op-1', 'op-2'], + reasons: ['too_much', 'other'], + saved_upload_id: null, + }); + const some = await buildProposal(done, session, DEFAULT_AI_STRINGS, { + accepted: new Set([1]), + }); + expect(decisionFor(some)).toBe('partial'); + expect(resolvePayload(some, 'partial', [], 'upload-9')).toEqual({ + decision: 'partial', + accepted_op_ids: ['op-2'], + rejected_op_ids: ['op-1'], + saved_upload_id: 'upload-9', + }); + const none = await buildProposal(done, session, DEFAULT_AI_STRINGS, { + accepted: new Set(), + }); + expect(decisionFor(none)).toBe('rejected'); + expect(none.verdict).toBe('nothing_accepted'); + }); + + it('reports questions and empty answers without touching the map', async () => { + const doc = officeDoc(); + const session = new FakeSession(doc); + const asked = await buildProposal( + job({ + state: 'done', + ops: [{ op: 'ask', why: '-', question: 'Which room?' }], + questions: ['Which room?', 'Which floor?'], + }), + session, + DEFAULT_AI_STRINGS, + ); + expect(asked.verdict).toBe('questions'); + expect(asked.questions).toEqual(['Which room?', 'Which floor?']); + expect(asked.rows[0].actionable).toBe(false); + expect(asked.trial_markup).toBeNull(); + const empty = await buildProposal( + job({ state: 'done', ops: [], summary: 'Nothing to do.' }), + session, + DEFAULT_AI_STRINGS, + ); + expect(empty.verdict).toBe('empty'); + expect(empty.rows).toEqual([]); + expect(anchor(doc, 'rooms')).toBeTruthy(); + }); +}); + +describe('connected ID renames', () => { + function swap() { + const doc = MapDocument.load( + svg( + 'Waters', + ), + ); + const waters = target(doc, 'zone-3.WAT'); + const food = target(doc, 'zone-3.CF'); + const ops: MapOp[] = [ + { + op: 'set_text', + why: 'Rename label', + target: target(doc, 'label'), + text: 'Confectionery & Food', + }, + { + op: 'set_id', + why: 'Temporary ID', + target: waters, + new_id: 'ai-zone-swap-tmp', + }, + { + op: 'set_id', + why: 'Swap left zone', + target: food, + new_id: 'zone-3.WAT', + }, + { + op: 'set_id', + why: 'Swap right zone', + target: { a: waters.a }, + new_id: 'zone-3.CF', + }, + ]; + return { doc, ops, session: new FakeSession(doc) }; + } + it('applies a complete temporary-ID swap and toggles its renames together', async () => { + const { doc, ops, session } = swap(); + const proposal = await buildProposal( + job({ state: 'done', ops }), + session, + DEFAULT_AI_STRINGS, + ); + expect(proposal.accepted).toEqual([0, 1, 2, 3]); + const trial = MapDocument.load(proposal.trial_markup!); + expect(trial.byId('zone-3.WAT')?.getAttribute('x')).toBe('10'); + expect(trial.byId('zone-3.CF')?.getAttribute('x')).toBe('100'); + expect(trial.byId('ai-zone-swap-tmp')).toBeNull(); + const unchecked = toggleRow(proposal, 2, false); + expect([...unchecked]).toEqual([0]); + const partial = await buildProposal( + job({ state: 'done', ops }), + session, + DEFAULT_AI_STRINGS, + { accepted: unchecked }, + ); + expect(toggleRow(partial, 1, true)).toEqual(new Set([0, 1, 2, 3])); + expect(doc.byId('zone-3.WAT')?.getAttribute('x')).toBe('100'); + }); + it('does not leave a temporary ID when a later swap step has a server warning', async () => { + const { ops, session } = swap(); + const proposal = await buildProposal( + job({ + state: 'done', + ops, + server_issues: { + 'op-3': 'zone-3.WAT is already an id in the map', + }, + }), + session, + DEFAULT_AI_STRINGS, + ); + expect(proposal.accepted).toEqual([0]); + const trial = MapDocument.load(proposal.trial_markup!); + expect(trial.byId('zone-3.WAT')?.getAttribute('x')).toBe('100'); + expect(trial.byId('zone-3.CF')?.getAttribute('x')).toBe('10'); + expect(trial.byId('ai-zone-swap-tmp')).toBeNull(); + expect(trial.byId('label')?.textContent).toBe('Confectionery & Food'); + }); + it('rejects all connected renames when the final target is stale', async () => { + const { ops, session } = swap(); + ops[3] = { ...ops[3], target: { a: 'missing-anchor' } } as MapOp; + const proposal = await buildProposal( + job({ state: 'done', ops }), + session, + DEFAULT_AI_STRINGS, + ); + expect(proposal.accepted).toEqual([0]); + const trial = MapDocument.load(proposal.trial_markup!); + expect(trial.byId('ai-zone-swap-tmp')).toBeNull(); + expect(trial.byId('zone-3.WAT')).toBeTruthy(); + expect(trial.byId('zone-3.CF')).toBeTruthy(); + }); +}); + +describe('inserting an overlay with a released ID', () => { + it('keeps a room renumbering and its replacement overlay together', async () => { + const doc = MapDocument.load( + svg( + '', + ), + ); + const ops: MapOp[] = [ + { + op: 'set_id', + why: 'Shift room 5', + target: target(doc, 'area-R19.05-status'), + new_id: 'area-R19.06-status', + }, + { + op: 'set_id', + why: 'Shift room 4', + target: target(doc, 'area-R19.04-status'), + new_id: 'area-R19.05-status', + }, + { + op: 'insert_shape', + why: 'New room 4', + kind: 'rect', + new_id: 'area-R19.04-status', + attrs: [ + { name: 'x', value: '30' }, + { name: 'y', value: '0' }, + { name: 'width', value: '5' }, + { name: 'height', value: '5' }, + ], + }, + ]; + const session = new FakeSession(doc); + const valid = await buildProposal( + job({ state: 'done', ops }), + session, + DEFAULT_AI_STRINGS, + ); + expect(valid.accepted).toEqual([0, 1, 2]); + expect(toggleRow(valid, 2, false)).toEqual(new Set()); + const incomplete = await buildProposal( + job({ + state: 'done', + ops, + server_issues: { 'op-3': 'Cannot create room' }, + }), + session, + DEFAULT_AI_STRINGS, + ); + expect(incomplete.accepted).toEqual([]); + expect(incomplete.trial_markup).toBeNull(); + const trial = doc; + expect(trial.byId('area-R19.04-status')?.getAttribute('x')).toBe('10'); + expect(trial.byId('area-R19.05-status')?.getAttribute('x')).toBe('20'); + expect(trial.byId('area-R19.06-status')).toBeNull(); + }); +}); + +describe('canvas expansion review', () => { + it('keeps the canvas and its edits together when any checkbox changes', async () => { + const doc = MapDocument.load(MAP); + const ops: MapOp[] = [ + { + op: 'set_attr', + why: '', + target: { a: doc.anchorOf(doc.root) }, + name: 'viewBox', + value: '0 -100 1000 600', + }, + { + op: 'set_attr', + why: '', + target: { id: 'desk-01.004' }, + name: 'fill', + value: 'red', + }, + ]; + const proposal = await buildProposal( + job({ state: 'done', ops }), + new FakeSession(doc), + DEFAULT_AI_STRINGS, + ); + expect(proposal.accepted).toEqual([0, 1]); + expect(toggleRow(proposal, 1, false)).toEqual(new Set()); + const unticked = await buildProposal( + job({ state: 'done', ops }), + new FakeSession(doc), + DEFAULT_AI_STRINGS, + { accepted: new Set() }, + ); + expect(toggleRow(unticked, 1, true)).toEqual(new Set([0, 1])); + }); +}); diff --git a/libs/map-editor/src/tests/ai-snapshot.spec.ts b/libs/map-editor/src/tests/ai-snapshot.spec.ts new file mode 100644 index 00000000000..ed6c76ba931 --- /dev/null +++ b/libs/map-editor/src/tests/ai-snapshot.spec.ts @@ -0,0 +1,150 @@ +import { + SNAPSHOT_OUTLINE_COLOUR, + snapshotMarkup, + snapshotSize, +} from '../lib/ai/snapshot'; +import { AttributeGeometryProvider } from '../lib/geometry'; +import { MapDocument } from '../lib/map-document'; +import { anchor, OFFICE } from './fixtures'; + +describe('snapshot', () => { + it('sizes the image at the viewBox aspect ratio with the long side at 2048', () => { + expect( + snapshotSize({ x: 0, y: 0, width: 1725.26, height: 619 }), + ).toEqual({ image_px: [2048, 735], units_per_px: 0.8424 }); + expect(snapshotSize({ x: 0, y: 0, width: 500, height: 1000 })).toEqual({ + image_px: [1024, 2048], + units_per_px: 0.4883, + }); + expect( + snapshotSize({ x: 0, y: 0, width: 1000, height: 500 }, 1000), + ).toEqual({ image_px: [1000, 500], units_per_px: 1 }); + expect( + snapshotSize({ x: 0, y: 0, width: 1, height: 10000 }, 100), + ).toEqual({ image_px: [1, 100], units_per_px: 1 }); + }); + + it('outlines the selection on a pixel-sized clone and leaves the document alone', () => { + const doc = MapDocument.load(OFFICE); + const desk = anchor(doc, 'table-01.001'); + const before = doc.root.outerHTML; + const count = doc.elementCount(); + const snapshot = snapshotMarkup(doc, { selection: [desk, 'e9999'] }); + expect(snapshot.image_px).toEqual([2048, 1024]); + expect(snapshot.units_per_px).toBe(0.4883); + expect(snapshot.outlined).toEqual([desk]); + expect(snapshot.markup).toContain('width="2048"'); + expect(snapshot.markup).toContain('height="1024"'); + expect(snapshot.markup).toContain( + `stroke="${SNAPSHOT_OUTLINE_COLOUR}"`, + ); + expect(snapshot.markup).toMatch( + /]*>table-01\.001<\/text>/); + expect(snapshot.markup).not.toContain('data-map-editor'); + expect(doc.root.outerHTML).toBe(before); + expect(doc.elementCount()).toBe(count); + }); + + it('draws nothing extra when nothing is selected', () => { + const doc = MapDocument.load(OFFICE); + const snapshot = snapshotMarkup(doc); + expect(snapshot.outlined).toEqual([]); + expect(snapshot.markup).not.toContain(SNAPSHOT_OUTLINE_COLOUR); + expect(snapshot.markup).toContain('width="2048"'); + }); +}); + +describe('snapshot tags', () => { + it('prints the id of each tagged element at its top left corner', () => { + const doc = MapDocument.load( + ``, + { geometry: new AttributeGeometryProvider() }, + ); + const room = doc.byId('area-1.01-status') as Element; + const anon = doc.root.querySelector('rect:not([id])') as Element; + const result = snapshotMarkup(doc, { + tags: [doc.anchorOf(room), doc.anchorOf(anon)], + }); + expect(result.markup).toContain('>area-1.01-status'); + expect((result.markup.match(/ { + it('includes coloured circles, freehand marks and arrowheads only in the snapshot', () => { + const doc = MapDocument.load(OFFICE); + const original = doc.serialise(); + const result = snapshotMarkup(doc, { + annotations: [ + { + n: 1, + kind: 'ellipse', + colour: 'red', + x: 90, + y: 180, + width: 70, + height: 60, + }, + { + n: 2, + kind: 'arrow', + colour: 'blue', + x: 50, + y: 50, + x2: 100, + y2: 200, + }, + { + n: 3, + kind: 'path', + colour: 'green', + points: [ + [100, 180], + [160, 180], + [160, 240], + [100, 180], + ], + }, + ], + }); + const root = new DOMParser().parseFromString( + result.markup, + 'image/svg+xml', + ); + expect(root.querySelectorAll('[data-annotation]')).toHaveLength(3); + expect( + root + .querySelector('[data-annotation="1"] ellipse') + ?.getAttribute('cx'), + ).toBe('125'); + expect( + root + .querySelector('[data-annotation="1"] ellipse') + ?.getAttribute('style'), + ).toContain('stroke:#dc2626'); + expect( + root.querySelector('[data-annotation="2"] path')?.getAttribute('d'), + ).toContain('L100 200M'); + expect( + root.querySelector('[data-annotation="3"] path')?.getAttribute('d'), + ).toBe('M100 180L160 180L160 240L100 180'); + expect(doc.serialise()).toBe(original); + }); +}); + +it('includes marks outside the map in the image without changing the document', () => { + const doc = MapDocument.load(OFFICE); + const before = doc.root.outerHTML; + const snapshot = snapshotMarkup(doc, { + annotations: [ + { n: 1, kind: 'box', x: 200, y: -200, width: 100, height: 200 }, + ], + }); + expect(snapshot.view_box!.y).toBeLessThan(-200); + expect(snapshot.view_box!.height).toBeGreaterThan(700); + expect(doc.view_box.y).toBe(0); + expect(doc.root.outerHTML).toBe(before); +}); diff --git a/libs/map-editor/src/tests/ai-state.spec.ts b/libs/map-editor/src/tests/ai-state.spec.ts new file mode 100644 index 00000000000..21908fb7187 --- /dev/null +++ b/libs/map-editor/src/tests/ai-state.spec.ts @@ -0,0 +1,412 @@ +import { buildProposal, Proposal } from '../lib/ai/proposal'; +import { + canCompose, + effectiveChoice, + initialState, + MapAiState, + modelOptions, + quotaBlock, + reduce, +} from '../lib/ai/state'; +import { DEFAULT_AI_STRINGS } from '../lib/ai/strings'; +import { MapDocument } from '../lib/map-document'; +import { capabilities, FakeSession, job } from './ai-fakes'; +import { anchor, OFFICE } from './fixtures'; + +const strings = DEFAULT_AI_STRINGS; + +describe('map AI reducer', () => { + let proposal: Proposal; + let state: MapAiState; + + beforeAll(async () => { + const doc = MapDocument.load(OFFICE); + const element = doc.byId('area-1.01-status') as Element; + proposal = await buildProposal( + job({ + id: 'job-p', + state: 'done', + summary: 'Recoloured.', + ops: [ + { + op: 'set_attr', + why: '-', + target: { + a: anchor(doc, 'area-1.01-status'), + id: 'area-1.01-status', + h: doc.hashOf(element), + }, + name: 'fill', + value: '#d1fae5', + }, + { + op: 'set_attr', + why: '-', + target: { a: 'e9999' }, + name: 'fill', + value: '#fff', + }, + ], + }), + new FakeSession(doc), + strings, + ); + }); + + beforeEach(() => { + state = initialState(); + }); + + const step = (events: Parameters[1][]) => { + for (const event of events) state = reduce(state, event, strings); + return state; + }; + + it('moves from idle through checking to composing when the feature is on', () => { + step([{ type: 'check' }]); + expect(state.phase.kind).toBe('checking'); + expect(state.announcement).toBe(strings.checking); + step([{ type: 'capabilities', capabilities: capabilities() }]); + expect(state.phase.kind).toBe('composing'); + expect(canCompose(state.phase)).toBe(true); + expect(quotaBlock(state.capabilities, strings)).toBeNull(); + }); + + it('is unavailable with the reason when the feature is off, hidden on a 404', () => { + step([ + { type: 'check' }, + { + type: 'capabilities', + capabilities: capabilities({ + enabled: false, + reason: 'no AI provider is configured for this domain', + }), + }, + ]); + expect(state.phase).toEqual({ + kind: 'unavailable', + reason: 'no AI provider is configured for this domain', + hidden: false, + }); + expect(state.announcement).toContain('no AI provider'); + state = initialState(); + step([ + { type: 'check' }, + { type: 'capabilities_failed', status: 404, message: 'not found' }, + ]); + expect(state.phase).toMatchObject({ + kind: 'unavailable', + hidden: true, + }); + state = initialState(); + step([ + { type: 'check' }, + { + type: 'capabilities_failed', + status: undefined, + message: 'offline', + }, + ]); + expect(state.phase).toEqual({ + kind: 'unavailable', + reason: 'offline', + hidden: false, + }); + }); + + it('keeps a chosen model while the capabilities offer it', () => { + const offered = capabilities({ + providers: [ + { + id: 'prov-1', + name: 'Signage Image Gen', + provider: 'OPENAI', + text_model: 'gpt-5.4-mini', + text_models: ['gpt-5.4-mini', 'gpt-6-luna'], + }, + { + id: 'prov-2', + name: 'Claude', + provider: 'ANTHROPIC', + text_model: 'claude-opus-5-5', + text_models: ['claude-opus-5-5', 'claude-fable-5-1'], + }, + ], + }); + expect(modelOptions(offered).map((option) => option.key)).toEqual([ + 'prov-1|gpt-5.4-mini', + 'prov-1|gpt-6-luna', + 'prov-2|claude-opus-5-5', + 'prov-2|claude-fable-5-1', + ]); + let next = reduce( + initialState(), + { type: 'capabilities', capabilities: offered }, + strings, + ); + expect(next.choice).toBeNull(); + expect(effectiveChoice(next)).toEqual({ + provider_id: 'prov-1', + model: 'gpt-5.4-mini', + }); + next = reduce( + next, + { + type: 'model_chosen', + choice: { provider_id: 'prov-2', model: 'claude-fable-5-1' }, + }, + strings, + ); + expect(effectiveChoice(next)).toEqual({ + provider_id: 'prov-2', + model: 'claude-fable-5-1', + }); + next = reduce( + next, + { type: 'capabilities', capabilities: offered }, + strings, + ); + expect(next.choice).toEqual({ + provider_id: 'prov-2', + model: 'claude-fable-5-1', + }); + next = reduce( + next, + { type: 'capabilities', capabilities: capabilities() }, + strings, + ); + expect(next.choice).toBeNull(); + expect(modelOptions(capabilities())).toHaveLength(1); + }); + + it('blocks the composer when the quota is used up', () => { + const none = capabilities({ + quota: { user_remaining_today: 0, domain_remaining_month: 5 }, + }); + expect(quotaBlock(none, strings)).toBe(strings.quota_user_exhausted); + const domain = capabilities({ + quota: { user_remaining_today: 3, domain_remaining_month: 0 }, + }); + expect(quotaBlock(domain, strings)).toBe( + strings.quota_domain_exhausted, + ); + }); + + it('records a person turn and follows the job to a preview', () => { + step([ + { type: 'check' }, + { type: 'capabilities', capabilities: capabilities() }, + { + type: 'submit', + instruction: 'recolour', + selected: 2, + snapshot: false, + at: 10, + }, + ]); + expect(state.phase.kind).toBe('submitting'); + expect(state.draft).toBe('recolour'); + expect(state.turns).toEqual([ + { + kind: 'person', + id: 'turn-1', + instruction: 'recolour', + selected: 2, + snapshot: false, + at: 10, + }, + ]); + const queued = job({ id: 'job-p', state: 'queued', version: 1 }); + step([{ type: 'job_accepted', job: queued, at: 11 }]); + expect(state.phase).toEqual({ + kind: 'running', + job: queued, + started_at: 11, + cancelling: false, + }); + expect(state.announcement).toBe(strings.states.generating); + const running = { ...queued, state: 'running' as const, version: 2 }; + step([ + { type: 'job_updated', job: running }, + { type: 'cancel_requested' }, + ]); + expect(state.phase).toMatchObject({ + kind: 'running', + job: running, + cancelling: true, + }); + step([{ type: 'proposal', proposal, at: 12 }]); + expect(state.phase).toEqual({ + kind: 'reviewing', + proposal, + validating: false, + }); + expect(state.parent_job_id).toBe('job-p'); + expect(state.turns[1]).toMatchObject({ + kind: 'proposal', + job_id: 'job-p', + outcome: 'previewing', + }); + expect(state.announcement).toBe('2 changes, 1 cannot be applied'); + step([{ type: 'previewed', proposal }]); + expect(state.phase).toEqual({ kind: 'applied', proposal }); + expect(state.announcement).toBe( + 'Applied 1 changes to the map. Keep them, or undo.', + ); + step([{ type: 'validating' }]); + expect(state.phase).toEqual({ + kind: 'reviewing', + proposal, + validating: true, + }); + step([ + { type: 'rows_changed', proposal }, + { type: 'previewed', proposal }, + ]); + step([ + { type: 'reason_toggled', reason: 'too_much' }, + { type: 'reason_toggled', reason: 'other' }, + { type: 'reason_toggled', reason: 'too_much' }, + ]); + expect(state.reasons).toEqual(['other']); + step([{ type: 'kept', proposal }]); + expect(state.phase.kind).toBe('composing'); + expect(state.reasons).toEqual([]); + expect(state.turns[1]).toMatchObject({ + kind: 'proposal', + outcome: 'partial', + }); + expect(state.announcement).toBe('Kept 1 of 2'); + }); + + it('records an undo with its reasons, a failure with its kind, and a cancel', () => { + step([ + { type: 'check' }, + { type: 'capabilities', capabilities: capabilities() }, + { + type: 'submit', + instruction: 'x', + selected: 0, + snapshot: true, + at: 1, + }, + { type: 'job_accepted', job: job({ id: 'job-p' }), at: 2 }, + { type: 'proposal', proposal, at: 3 }, + { type: 'previewed', proposal }, + { type: 'undone', proposal, reasons: ['wrong_values'] }, + ]); + expect(state.phase.kind).toBe('composing'); + expect(state.turns[1]).toMatchObject({ + kind: 'proposal', + outcome: 'undone', + reasons: ['wrong_values'], + }); + expect(state.announcement).toBe(strings.outcomes.undone); + + const failed = job({ + id: 'job-f', + state: 'failed', + error_kind: 'moderation', + }); + step([ + { + type: 'submit', + instruction: 'y', + selected: 0, + snapshot: false, + at: 4, + }, + { type: 'job_accepted', job: failed, at: 5 }, + { + type: 'job_failed', + job: failed, + error: { + kind: 'refused', + message: strings.states.refused, + retryable: false, + job: failed, + }, + at: 6, + }, + ]); + expect(state.phase).toMatchObject({ + kind: 'error', + error: { kind: 'refused' }, + }); + expect(state.turns[state.turns.length - 1]).toMatchObject({ + kind: 'status', + text: strings.states.refused, + }); + expect(state.parent_job_id).toBe('job-f'); + expect(canCompose(state.phase)).toBe(true); + + step([ + { + type: 'submit', + instruction: 'z', + selected: 0, + snapshot: false, + at: 7, + }, + { type: 'job_accepted', job: job({ id: 'job-c' }), at: 8 }, + { + type: 'job_cancelled', + job: job({ id: 'job-c', state: 'cancelled' }), + at: 9, + }, + ]); + expect(state.phase).toMatchObject({ kind: 'cancelled' }); + expect(state.announcement).toBe(strings.states.cancelled); + + step([ + { + type: 'submit_failed', + error: { + kind: 'busy', + message: strings.states.busy, + retryable: true, + }, + }, + ]); + expect(state.phase).toMatchObject({ + kind: 'error', + error: { kind: 'busy' }, + }); + + step([{ type: 'reset' }]); + expect(state.phase.kind).toBe('composing'); + expect(state.parent_job_id).toBeNull(); + expect(state.draft).toBe(''); + expect(state.turns.length).toBeGreaterThan(0); + }); + + it('ignores a submit while a job is running and a draft while idle', () => { + step([ + { type: 'check' }, + { type: 'capabilities', capabilities: capabilities() }, + ]); + step([ + { + type: 'submit', + instruction: 'a', + selected: 0, + snapshot: false, + at: 1, + }, + { type: 'job_accepted', job: job(), at: 2 }, + ]); + const before = state; + step([ + { + type: 'submit', + instruction: 'b', + selected: 0, + snapshot: false, + at: 3, + }, + ]); + expect(state).toBe(before); + step([{ type: 'draft', text: 'c' }]); + expect(state.draft).toBe('c'); + }); +}); diff --git a/libs/map-editor/src/tests/ai-thread.spec.ts b/libs/map-editor/src/tests/ai-thread.spec.ts new file mode 100644 index 00000000000..0c56a49ded9 --- /dev/null +++ b/libs/map-editor/src/tests/ai-thread.spec.ts @@ -0,0 +1,1057 @@ +import { signal } from '@angular/core'; +import { MapAiModelChoice } from '../lib/ai/state'; +import { DEFAULT_AI_STRINGS } from '../lib/ai/strings'; +import { errorFromJob, errorFromStatus, MapAiThread } from '../lib/ai/thread'; +import { + MAP_AI_CONTEXT_SCHEMA, + MapAIAnnotation, + MapAIEditRequest, + MapAIJob, +} from '../lib/ai/types'; +import { MapDocument, Target } from '../lib/map-document'; +import { MapOp } from '../lib/ops'; +import { + capabilities, + FakeSession, + FakeTransport, + httpError, + job, + jobSequence, +} from './ai-fakes'; +import { OFFICE } from './fixtures'; + +const no_sleep = async () => undefined; + +function target(doc: MapDocument, id: string): Target { + const element = doc.byId(id); + if (!element) throw new Error(`no ${id}`); + return { a: doc.anchorOf(element), id, h: doc.hashOf(element) }; +} + +function recolour(doc: MapDocument, id: string, value: string): MapOp { + return { + op: 'set_attr', + why: '-', + target: target(doc, id), + name: 'fill', + value, + }; +} + +function fill(doc: MapDocument, id: string): string | null { + return doc.byId(id)?.getAttribute('fill') ?? null; +} + +async function flush(times = 10): Promise { + for (let i = 0; i < times; i++) await Promise.resolve(); +} + +function setup(ops: MapOp[], final: Partial = {}) { + const doc = MapDocument.load(OFFICE); + const session = new FakeSession(doc); + const sequence = jobSequence({ summary: 'Recoloured.', ops, ...final }); + const transport = new FakeTransport({ + edit: sequence.accepted, + polls: sequence.polls, + }); + const thread = new MapAiThread(session, 'zone-1', { + transport, + sleep: no_sleep, + now: () => 1000, + idempotency_key: () => 'key-1', + }); + return { doc, session, transport, thread, sequence }; +} + +describe('MapAiThread', () => { + it('refreshes remaining usage without clearing the applied proposal', async () => { + const { thread, transport } = setup([]); + await thread.load(); + transport.options.capabilities = capabilities({ + quota: { user_remaining_today: 0, domain_remaining_month: 941 }, + }); + await thread.submit('No changes needed'); + await flush(); + expect(thread.capabilities()?.quota.user_remaining_today).toBe(0); + expect(thread.capabilities()?.quota.domain_remaining_month).toBe(941); + expect( + thread.state().turns.some((turn) => turn.kind === 'proposal'), + ).toBe(true); + expect(thread.can_send()).toBe(false); + }); + + it('keeps the known quota when a refresh comes back without one', async () => { + const { thread, transport } = setup([]); + await thread.load(); + transport.options.capabilities = { enabled: true }; + await thread.submit('No changes needed'); + await flush(); + expect(thread.capabilities()?.quota).toBeDefined(); + expect(thread.can_send()).toBe(true); + }); + + it('refreshes the quota when a send is refused for quota', async () => { + const { thread, transport } = setup([]); + await thread.load(); + transport.options.edit = httpError(429, 'daily limit reached'); + transport.options.capabilities = capabilities({ + quota: { user_remaining_today: 0, domain_remaining_month: 941 }, + }); + await thread.submit('Recolour'); + await flush(); + expect(thread.capabilities()?.quota.user_remaining_today).toBe(0); + expect(thread.can_send()).toBe(false); + }); + + it('sends the chosen model, remembered for the next thread', async () => { + const stored: { choice: MapAiModelChoice | null } = { choice: null }; + const memory = { + read: () => stored.choice, + write: (choice: MapAiModelChoice | null) => { + stored.choice = choice; + }, + }; + const offered = capabilities({ + providers: [ + { + id: 'prov-1', + name: 'Signage Image Gen', + provider: 'OPENAI', + text_model: 'gpt-5.4-mini', + text_models: ['gpt-5.4-mini', 'gpt-6-luna'], + }, + { + id: 'prov-2', + name: 'Claude', + provider: 'ANTHROPIC', + text_model: 'claude-opus-5-5', + text_models: ['claude-opus-5-5', 'claude-fable-5-1'], + }, + ], + }); + const doc = MapDocument.load(OFFICE); + const session = new FakeSession(doc); + const sequence = jobSequence({ + summary: 'Recoloured.', + ops: [recolour(doc, 'area-1.01-status', '#d1fae5')], + }); + const transport = new FakeTransport({ + edit: sequence.accepted, + polls: sequence.polls, + capabilities: offered, + }); + const options = { + transport, + sleep: no_sleep, + now: () => 1000, + idempotency_key: () => 'key-1', + memory, + }; + const thread = new MapAiThread(session, 'zone-1', options); + await thread.load(); + expect(thread.choice()).toEqual({ + provider_id: 'prov-1', + model: 'gpt-5.4-mini', + }); + expect(thread.model_options()).toHaveLength(4); + + thread.chooseModel({ + provider_id: 'prov-2', + model: 'claude-fable-5-1', + }); + expect(stored.choice).toEqual({ + provider_id: 'prov-2', + model: 'claude-fable-5-1', + }); + await thread.submit('recolour Meeting 1.01 light green'); + expect(transport.edits[0].body).toMatchObject({ + provider_id: 'prov-2', + model: 'claude-fable-5-1', + }); + + const again = new MapAiThread(session, 'zone-1', options); + expect(again.state().choice).toEqual({ + provider_id: 'prov-2', + model: 'claude-fable-5-1', + }); + await again.load(); + expect(again.choice()).toEqual({ + provider_id: 'prov-2', + model: 'claude-fable-5-1', + }); + + const forgetful = new MapAiThread(session, 'zone-1', { + ...options, + memory: null, + }); + expect(forgetful.state().choice).toBeNull(); + }); + + it('previews a proposal on the map, then keeps it and refines from it', async () => { + const { doc, session, transport, thread, sequence } = setup([ + recolour(MapDocument.load(OFFICE), 'area-1.01-status', '#d1fae5'), + ]); + await thread.load(); + expect(thread.phase().kind).toBe('composing'); + expect(thread.can_send()).toBe(true); + session.select([doc.anchorOf(doc.byId('table-01.001') as Element)]); + + await thread.submit(' recolour Meeting 1.01 light green '); + const edit = transport.edits[0].body as MapAIEditRequest; + expect(edit).toMatchObject({ + zone_id: 'zone-1', + instruction: 'recolour Meeting 1.01 light green', + parent_job_id: null, + markup_upload_id: null, + idempotency_key: 'key-1', + }); + expect(edit.context.schema).toBe(MAP_AI_CONTEXT_SCHEMA); + expect(edit.context.markup.selected).toEqual(session.selection()); + expect(transport.job_polls).toHaveLength(2); + + expect(thread.phase().kind).toBe('applied'); + expect(fill(doc, 'area-1.01-status')).toBe('#d1fae5'); + expect(session.applied).toHaveLength(1); + expect(session.applied[0].label).toBe('AI: Recoloured.'); + const proposal = thread.proposal(); + expect(proposal?.rows[0]).toMatchObject({ + applied: true, + checked: true, + text: 'Change fill of area-1.01-status to #d1fae5', + }); + expect(thread.turns().map((turn) => turn.kind)).toEqual([ + 'person', + 'proposal', + ]); + expect(thread.turns()[1]).toMatchObject({ outcome: 'previewing' }); + expect(thread.announcement()).toBe( + 'Applied 1 changes to the map. Keep them, or undo.', + ); + + await thread.keep(); + expect(transport.resolves).toHaveLength(1); + expect(transport.resolves[0].url).toContain( + `/jobs/${sequence.accepted.id}/resolve`, + ); + expect(transport.resolves[0].body).toEqual({ + decision: 'applied', + accepted_op_ids: ['op-1'], + rejected_op_ids: [], + saved_upload_id: null, + }); + expect(thread.phase().kind).toBe('composing'); + expect(thread.state().parent_job_id).toBe(sequence.accepted.id); + expect(thread.turns()[1]).toMatchObject({ outcome: 'kept' }); + expect(fill(doc, 'area-1.01-status')).toBe('#d1fae5'); + + const second = jobSequence({ + ops: [recolour(doc, 'area-1.02-status', '#fee')], + }); + transport.options.edit = second.accepted; + transport.polls = second.polls; + await thread.submit('and the other one pink'); + expect( + (transport.edits[1].body as MapAIEditRequest).parent_job_id, + ).toBe(sequence.accepted.id); + expect(thread.phase().kind).toBe('applied'); + expect(fill(doc, 'area-1.02-status')).toBe('#fee'); + }); + + it('undoes the preview and records the rejection with its reasons', async () => { + const { doc, session, transport, thread } = setup([ + recolour(MapDocument.load(OFFICE), 'area-1.01-status', '#d1fae5'), + ]); + await thread.load(); + await thread.submit('recolour'); + expect(fill(doc, 'area-1.01-status')).toBe('#d1fae5'); + thread.toggleReason('wrong_values'); + thread.toggleReason('too_much'); + await thread.undo(); + expect(fill(doc, 'area-1.01-status')).toBeNull(); + expect(session.can_undo()).toBe(false); + expect(transport.resolves[0].body).toEqual({ + decision: 'rejected', + accepted_op_ids: [], + rejected_op_ids: ['op-1'], + reasons: ['wrong_values', 'too_much'], + saved_upload_id: null, + }); + expect(thread.phase().kind).toBe('composing'); + expect(thread.turns()[1]).toMatchObject({ + outcome: 'undone', + reasons: ['wrong_values', 'too_much'], + }); + expect(thread.state().parent_job_id).toBeTruthy(); + }); + + it('settles the preview when the map changes outside the panel', async () => { + const op = recolour( + MapDocument.load(OFFICE), + 'area-1.01-status', + '#d1fae5', + ); + + const undone = setup([op]); + await undone.thread.load(); + await undone.thread.submit('recolour'); + undone.session.undo(); + undone.thread.mapChanged(); + await undone.thread.keep(); + expect(fill(undone.doc, 'area-1.01-status')).toBeNull(); + expect(undone.thread.phase().kind).toBe('composing'); + expect(undone.thread.turns()[1]).toMatchObject({ outcome: 'undone' }); + expect(undone.transport.resolves).toHaveLength(1); + expect(undone.transport.resolves[0].body).toMatchObject({ + decision: 'rejected', + }); + + const reverted = setup([op]); + await reverted.thread.load(); + await reverted.thread.submit('recolour'); + reverted.session.dirty.set(false); + reverted.session.version.update((version) => version + 1); + reverted.thread.mapChanged(); + await reverted.thread.keep(); + expect(reverted.thread.turns()[1]).toMatchObject({ outcome: 'undone' }); + + const edited = setup([op]); + await edited.thread.load(); + await edited.thread.submit('recolour'); + edited.session.apply( + [recolour(edited.doc, 'area-1.02-status', '#fee2e2')], + 'Fill', + ); + edited.thread.mapChanged(); + await edited.thread.keep(); + expect(fill(edited.doc, 'area-1.01-status')).toBe('#d1fae5'); + expect(fill(edited.doc, 'area-1.02-status')).toBe('#fee2e2'); + expect(edited.thread.turns()[1]).toMatchObject({ outcome: 'kept' }); + expect(edited.transport.resolves).toHaveLength(1); + expect(edited.transport.resolves[0].body).toMatchObject({ + decision: 'applied', + }); + }); + + it('keeps a valid preview when another checkbox exceeds the batch size limit', async () => { + const base = MapDocument.load(OFFICE); + const { doc, thread, session } = setup([ + recolour(base, 'area-1.01-status', '#d1fae5'), + { + op: 'duplicate', + why: 'adds furniture', + target: target(base, 'desks'), + translate: [0, 0], + count: 10, + }, + ]); + await thread.load(); + await thread.submit('Recolour and copy desks'); + expect(thread.proposal()?.verdict).toBe('blocked'); + expect(thread.proposal()?.rows.every((row) => !row.applied)).toBe(true); + expect(thread.turns()[1]).toMatchObject({ outcome: 'not_applied' }); + await thread.toggle(1, false); + const before = doc.serialise(); + expect(fill(doc, 'area-1.01-status')).toBe('#d1fae5'); + await thread.toggle(1, true); + expect(doc.serialise()).toBe(before); + expect(thread.phase().kind).toBe('applied'); + expect(thread.proposal()?.accepted).toEqual([0]); + expect(thread.proposal()?.rows[1].checked).toBe(false); + expect(thread.selection_error()).toContain( + 'Your previous preview is unchanged', + ); + expect(session.history.length).toBe(1); + await thread.toggle(0, false); + expect(thread.selection_error()).toBe(''); + expect(thread.turns()[1]).toMatchObject({ outcome: 'not_applied' }); + }); + + it('swaps the preview in one step when a changed selection is validated', async () => { + const base = MapDocument.load(OFFICE); + const { thread, session } = setup([ + recolour(base, 'area-1.01-status', '#d1fae5'), + recolour(base, 'area-1.02-status', '#fee2e2'), + ]); + await thread.load(); + await thread.submit('Recolour both'); + const undo = session.undo.bind(session); + const apply = session.apply.bind(session); + let between = false; + const seen: boolean[] = []; + session.undo = () => { + between = true; + queueMicrotask(() => seen.push(between)); + return undo(); + }; + session.apply = (...args) => { + between = false; + return apply(...args); + }; + await thread.toggle(1, false); + await thread.toggle(1, true); + expect(seen).toEqual([false, false]); + expect(thread.proposal()?.accepted).toEqual([0, 1]); + }); + + it('keeps the picked reasons when the preview is undone from the toolbar', async () => { + const base = MapDocument.load(OFFICE); + const { thread, session, transport } = setup([ + recolour(base, 'area-1.01-status', '#d1fae5'), + ]); + await thread.load(); + await thread.submit('Recolour'); + thread.toggleReason('wrong_elements'); + session.undo(); + thread.mapChanged(); + await flush(); + expect(transport.resolves[0].body).toMatchObject({ + decision: 'rejected', + reasons: ['wrong_elements'], + }); + }); + + it('leaves other edits alone when a preview with nothing on the map is undone', async () => { + const base = MapDocument.load(OFFICE); + const { thread, session, doc } = setup([ + recolour(base, 'area-1.01-status', '#d1fae5'), + ]); + session.apply( + [recolour(base, 'area-1.02-status', '#fee2e2')], + 'My edit', + ); + await thread.load(); + await thread.submit('Recolour'); + await thread.toggle(0, false); + expect(doc.byId('area-1.01-status')?.getAttribute('fill')).toBeNull(); + await thread.undo(); + expect(doc.byId('area-1.02-status')?.getAttribute('fill')).toBe( + '#fee2e2', + ); + }); + + it('remembers where removed elements stood so their pins stay on the map', async () => { + const base = MapDocument.load(OFFICE); + const { thread } = setup([ + { op: 'delete', why: 'remove', target: target(base, 'plant-1') }, + ]); + await thread.load(); + await thread.submit('Remove the first plant'); + const row = thread.proposal()?.rows[0]; + expect(row?.applied).toBe(true); + expect(row?.box_before).toEqual([890, 390, 20, 20]); + }); + + it('points added rows at the elements they create', async () => { + const base = MapDocument.load(OFFICE); + const { doc, thread } = setup([ + { + op: 'insert_shape', + why: 'adds a box', + parent: target(base, 'rooms'), + kind: 'rect', + attrs: [ + { name: 'x', value: '300' }, + { name: 'y', value: '300' }, + { name: 'width', value: '20' }, + { name: 'height', value: '20' }, + ], + new_id: 'ai-box', + }, + { + op: 'duplicate', + why: 'copies the plants', + target: target(base, 'plants'), + translate: [0, 50], + new_id: 'plants-copy', + }, + ]); + await thread.load(); + await thread.submit('Add a box and copy the plants'); + const anchorOf = (id: string) => doc.anchorOf(doc.byId(id)!); + expect(thread.proposal()?.rows[0].anchors).toEqual([ + anchorOf('ai-box'), + ]); + expect(thread.proposal()?.rows[1].anchors).toEqual([ + anchorOf('plants-copy'), + ]); + await thread.toggle(0, false); + expect(thread.proposal()?.rows[0].anchors).toEqual([]); + expect(thread.proposal()?.rows[1].anchors).toEqual([ + anchorOf('plants-copy'), + ]); + }); + + it('toggles two complete 41-piece plant copies independently', async () => { + const markup = `${'existing metadata '.repeat(3000)}${Array.from({ length: 41 }, (_, i) => ``).join('')}`; + const doc = MapDocument.load(markup); + const session = new FakeSession(doc); + const ops: MapOp[] = [ + { + op: 'group', + why: 'complete plant', + targets: [...doc.byId('plants')!.children].map((el) => ({ + a: doc.anchorOf(el), + h: doc.hashOf(el), + })), + new_id: 'ai-plant-01', + }, + { + op: 'duplicate', + why: 'first plant', + target: { id: 'ai-plant-01' }, + new_id: 'ai-plant-02', + translate: [100, 0], + }, + { + op: 'duplicate', + why: 'second plant', + target: { id: 'ai-plant-01' }, + new_id: 'ai-plant-03', + translate: [200, 0], + }, + ]; + const sequence = jobSequence({ ops }); + const thread = new MapAiThread(session, 'zone-1', { + transport: new FakeTransport({ + edit: sequence.accepted, + polls: sequence.polls, + }), + sleep: no_sleep, + }); + await thread.load(); + await thread.submit('Add two plants'); + expect(thread.proposal()?.verdict).toBe('ready'); + expect(doc.byId('ai-plant-02')!.children).toHaveLength(41); + expect(doc.byId('ai-plant-03')!.children).toHaveLength(41); + await thread.toggle(1, false); + expect(doc.byId('ai-plant-02')).toBeNull(); + expect(doc.byId('ai-plant-03')!.children).toHaveLength(41); + await thread.toggle(1, true); + expect(doc.byId('ai-plant-02')!.children).toHaveLength(41); + expect(doc.byId('ai-plant-03')!.children).toHaveLength(41); + await thread.toggle(2, false); + expect(doc.byId('ai-plant-02')!.children).toHaveLength(41); + expect(doc.byId('ai-plant-03')).toBeNull(); + await thread.toggle(2, true); + expect(thread.proposal()?.accepted).toEqual([0, 1, 2]); + expect(doc.byId('ai-plant-03')!.children).toHaveLength(41); + }); + + it('re-applies the preview when a row is unticked and resolves as partial', async () => { + const base = MapDocument.load(OFFICE); + const { doc, session, transport, thread } = setup([ + recolour(base, 'area-1.01-status', '#d1fae5'), + recolour(base, 'area-1.02-status', '#fee'), + ]); + await thread.load(); + await thread.submit('recolour both'); + expect(fill(doc, 'area-1.01-status')).toBe('#d1fae5'); + expect(fill(doc, 'area-1.02-status')).toBe('#fee'); + + await thread.toggle(1, false); + expect(thread.phase().kind).toBe('applied'); + expect(fill(doc, 'area-1.01-status')).toBe('#d1fae5'); + expect(fill(doc, 'area-1.02-status')).toBeNull(); + expect(session.applied).toHaveLength(2); + expect([...(session.applied[1].options?.skip || [])]).toEqual([1]); + expect(session.history.length).toBe(1); + expect(thread.proposal()?.accepted).toEqual([0]); + expect(thread.proposal()?.verdict).toBe('partial'); + + await thread.toggle(0, false); + expect(thread.phase()).toMatchObject({ + kind: 'reviewing', + validating: false, + }); + expect(thread.proposal()?.verdict).toBe('nothing_accepted'); + expect(fill(doc, 'area-1.01-status')).toBeNull(); + expect(session.history.length).toBe(0); + + await thread.toggle(1, true); + expect(thread.phase().kind).toBe('applied'); + expect(fill(doc, 'area-1.02-status')).toBe('#fee'); + expect(fill(doc, 'area-1.01-status')).toBeNull(); + + await thread.keep(); + expect(transport.resolves[0].body).toEqual({ + decision: 'partial', + accepted_op_ids: ['op-2'], + rejected_op_ids: ['op-1'], + saved_upload_id: null, + }); + expect(thread.turns()[1]).toMatchObject({ outcome: 'partial' }); + }); + + it('locks an op the validator rejects and applies the rest', async () => { + const { doc, thread } = setup([ + recolour(MapDocument.load(OFFICE), 'area-1.01-status', '#d1fae5'), + { + op: 'set_attr', + why: '-', + target: { a: 'e9999' }, + name: 'fill', + value: '#fff', + }, + ]); + await thread.load(); + await thread.submit('recolour'); + expect(thread.phase().kind).toBe('applied'); + const proposal = thread.proposal(); + expect(proposal?.rows[1]).toMatchObject({ + locked: true, + applied: false, + checked: false, + }); + expect(proposal?.invalid_count).toBe(1); + expect(proposal?.verdict).toBe('partial'); + expect(fill(doc, 'area-1.01-status')).toBe('#d1fae5'); + }); + + it('leaves a server-doubted op unticked until the person ticks it', async () => { + const { doc, thread } = setup( + [recolour(MapDocument.load(OFFICE), 'area-1.01-status', '#d1fae5')], + { server_issues: { 'op-1': 'unknown anchor' } }, + ); + await thread.load(); + await thread.submit('recolour'); + expect(thread.phase()).toMatchObject({ + kind: 'reviewing', + validating: false, + }); + expect(thread.proposal()?.verdict).toBe('nothing_accepted'); + expect(thread.proposal()?.rows[0].server_issue).toBe('unknown anchor'); + expect(fill(doc, 'area-1.01-status')).toBeNull(); + await thread.toggle(0, true); + expect(thread.phase().kind).toBe('applied'); + expect(fill(doc, 'area-1.01-status')).toBe('#d1fae5'); + }); + + it('shows a question as a reply prompt and carries the job into the answer', async () => { + const { doc, transport, thread, sequence } = setup( + [{ op: 'ask', why: '-', question: 'Which room?' }], + { questions: ['Which room?'], summary: '' }, + ); + await thread.load(); + await thread.submit('recolour the room'); + expect(thread.phase()).toMatchObject({ + kind: 'reviewing', + validating: false, + }); + expect(thread.proposal()?.verdict).toBe('questions'); + expect(thread.proposal()?.questions).toEqual(['Which room?']); + expect(thread.announcement()).toBe('Which room?'); + expect(fill(doc, 'area-1.01-status')).toBeNull(); + expect(thread.can_send()).toBe(true); + const answer = jobSequence({ + ops: [recolour(doc, 'area-1.01-status', '#abc')], + }); + transport.options.edit = answer.accepted; + transport.polls = answer.polls; + await thread.submit('Meeting 1.01'); + expect( + (transport.edits[1].body as MapAIEditRequest).parent_job_id, + ).toBe(sequence.accepted.id); + expect(thread.phase().kind).toBe('applied'); + }); + + it('reports an empty answer', async () => { + const { thread } = setup([], { summary: 'Nothing to do.' }); + await thread.load(); + await thread.submit('do nothing'); + expect(thread.phase()).toMatchObject({ kind: 'reviewing' }); + expect(thread.proposal()?.verdict).toBe('empty'); + expect(thread.announcement()).toBe( + 'No changes proposed. The AI said: Nothing to do.', + ); + }); + + it('turns job failures into named errors and retries the same instruction', async () => { + const failed = jobSequence({ + state: 'failed', + error_kind: 'timeout', + error_message: 'slow', + }); + const doc = MapDocument.load(OFFICE); + const transport = new FakeTransport({ + edit: failed.accepted, + polls: failed.polls, + }); + const thread = new MapAiThread(new FakeSession(doc), 'zone-1', { + transport, + sleep: no_sleep, + }); + await thread.load(); + await thread.submit('recolour'); + expect(thread.phase()).toMatchObject({ + kind: 'error', + error: { kind: 'timeout', retryable: true }, + }); + thread.setDraft('half typed'); + expect(thread.turns()[thread.turns().length - 1]).toMatchObject({ + kind: 'status', + text: DEFAULT_AI_STRINGS.states.timeout, + }); + + const again = jobSequence({ ops: [] }); + transport.options.edit = again.accepted; + transport.polls = again.polls; + await thread.retry(); + expect(transport.edits).toHaveLength(2); + expect((transport.edits[1].body as MapAIEditRequest).instruction).toBe( + 'recolour', + ); + expect(thread.phase().kind).toBe('reviewing'); + + expect( + errorFromJob(job({ error_kind: 'moderation' }), DEFAULT_AI_STRINGS), + ).toMatchObject({ kind: 'refused', retryable: false }); + expect( + errorFromJob( + job({ error_kind: 'vendor', error_message: 'boom' }), + DEFAULT_AI_STRINGS, + ), + ).toMatchObject({ kind: 'vendor', message: 'boom' }); + expect( + errorFromJob( + job({ error_kind: 'invalid_response' }), + DEFAULT_AI_STRINGS, + ), + ).toMatchObject({ kind: 'invalid', retryable: true }); + }); + + it('maps submit failures by status', async () => { + for (const [status, message, kind, retryable] of [ + [503, 'too many map edits are running right now', 'busy', true], + [ + 503, + 'no AI provider is configured for this domain', + 'not_configured', + false, + ], + [ + 429, + 'you have used your map editing allowance for today', + 'quota', + false, + ], + [403, '', 'forbidden', false], + [422, 'context is required', 'request', false], + [502, '', 'vendor', true], + [undefined, 'offline', 'network', true], + ] as const) { + const transport = new FakeTransport({ + edit: httpError(status, message), + }); + const thread = new MapAiThread( + new FakeSession(MapDocument.load(OFFICE)), + 'zone-1', + { transport, sleep: no_sleep }, + ); + await thread.load(); + await thread.submit('x'); + expect(thread.phase()).toMatchObject({ + kind: 'error', + error: { kind, retryable }, + }); + } + expect(errorFromStatus(504, '', DEFAULT_AI_STRINGS)).toMatchObject({ + kind: 'timeout', + }); + expect(errorFromStatus(500, '', DEFAULT_AI_STRINGS)).toMatchObject({ + kind: 'unknown', + retryable: true, + }); + }); + + it('hides the panel on a 404, explains a disabled feature and blocks on an empty quota', async () => { + const missing = new MapAiThread( + new FakeSession(MapDocument.load(OFFICE)), + 'zone-1', + { + transport: new FakeTransport({ + capabilities: httpError(404, 'not found'), + }), + }, + ); + await missing.load(); + expect(missing.phase()).toMatchObject({ + kind: 'unavailable', + hidden: true, + }); + + const off = new MapAiThread( + new FakeSession(MapDocument.load(OFFICE)), + 'zone-1', + { + transport: new FakeTransport({ + capabilities: capabilities({ + enabled: false, + reason: 'the feature is switched off', + }), + }), + }, + ); + await off.load(); + expect(off.phase()).toEqual({ + kind: 'unavailable', + reason: 'the feature is switched off', + hidden: false, + }); + + const transport = new FakeTransport({ + capabilities: capabilities({ + quota: { user_remaining_today: 0, domain_remaining_month: 10 }, + }), + }); + const spent = new MapAiThread( + new FakeSession(MapDocument.load(OFFICE)), + 'zone-1', + { transport }, + ); + await spent.load(); + expect(spent.phase().kind).toBe('composing'); + expect(spent.quota_block()).toBe( + DEFAULT_AI_STRINGS.quota_user_exhausted, + ); + expect(spent.can_send()).toBe(false); + await spent.submit('x'); + expect(transport.edits).toHaveLength(0); + }); + + it('refuses an instruction over the limit without sending it', async () => { + const transport = new FakeTransport({ + capabilities: capabilities({ + limits: { + max_context_bytes: 1024 * 512, + max_ops: 80, + max_instruction_chars: 5, + }, + }), + }); + const thread = new MapAiThread( + new FakeSession(MapDocument.load(OFFICE)), + 'zone-1', + { transport }, + ); + await thread.load(); + await thread.submit('too long an instruction'); + expect(transport.edits).toHaveLength(0); + expect(thread.announcement()).toBe(DEFAULT_AI_STRINGS.too_long); + expect(thread.phase().kind).toBe('composing'); + }); + + it('sends the same coloured marks to the snapshot and context while drawing can continue', async () => { + const sequence = jobSequence({ ops: [] }); + const transport = new FakeTransport({ + edit: sequence.accepted, + polls: sequence.polls, + }); + const annotations = signal([ + { + n: 1, + kind: 'ellipse', + colour: 'red', + text: 'red circle', + x: 90, + y: 190, + width: 60, + height: 50, + }, + ]); + const session = Object.assign( + new FakeSession(MapDocument.load(OFFICE)), + { annotations }, + ); + let captured: readonly MapAIAnnotation[] = []; + const thread = new MapAiThread(session, 'zone-1', { + transport, + sleep: no_sleep, + upload: async () => 'upload-1', + snapshot: async (_session, _selection, _tags, marks) => { + captured = marks; + annotations.set([]); + return { + blob: new Blob(['png']), + image_px: [1024, 512], + units_per_px: 0.9766, + }; + }, + }); + await thread.load(); + await thread.submit('Remove the chairs from the desk circled in red'); + const edit = transport.edits[0].body as MapAIEditRequest; + expect(edit.context.markup.annotations).toEqual(captured); + expect(edit.context.markup.annotations[0].colour).toBe('red'); + expect(edit.context.markup.annotations[0].text).toBe('red circle'); + expect(edit.context.markup.annotations[0].hits).toContain( + session.doc.anchorOf(session.doc.byId('table-01.001')!), + ); + expect(edit.context.shortlist).toContain( + session.doc.anchorOf(session.doc.byId('table-01.001')!), + ); + expect(annotations()).toEqual([]); + }); + + it('sends the snapshot through the upload port and its size in the context', async () => { + const sequence = jobSequence({ ops: [] }); + const transport = new FakeTransport({ + edit: sequence.accepted, + polls: sequence.polls, + }); + const uploads: string[] = []; + const thread = new MapAiThread( + new FakeSession(MapDocument.load(OFFICE)), + 'zone-1', + { + transport, + sleep: no_sleep, + upload: async (_blob, name) => { + uploads.push(name); + return 'upload-1'; + }, + snapshot: async () => ({ + blob: new Blob(['png']), + image_px: [1024, 512], + units_per_px: 0.9766, + }), + }, + ); + await thread.load(); + await thread.submit('recolour'); + expect(uploads).toEqual(['map-snapshot.png']); + const edit = transport.edits[0].body as MapAIEditRequest; + expect(edit.markup_upload_id).toBe('upload-1'); + expect(edit.context.image_px).toEqual([1024, 512]); + expect(edit.context.units_per_px).toBe(0.9766); + expect(thread.turns()[0]).toMatchObject({ + kind: 'person', + snapshot: true, + }); + }); + + it('does not send when the snapshot upload fails', async () => { + const sequence = jobSequence({ ops: [] }); + const transport = new FakeTransport({ + edit: sequence.accepted, + polls: sequence.polls, + }); + const thread = new MapAiThread( + new FakeSession(MapDocument.load(OFFICE)), + 'zone-1', + { + transport, + sleep: no_sleep, + upload: async () => { + throw new Error('storage down'); + }, + snapshot: async () => ({ + blob: new Blob(['png']), + image_px: [1024, 512], + units_per_px: 0.9766, + }), + }, + ); + await thread.load(); + await thread.submit('recolour'); + expect(transport.edits).toHaveLength(0); + expect(thread.phase()).toMatchObject({ + kind: 'error', + error: { + message: DEFAULT_AI_STRINGS.snapshot_failed, + retryable: true, + }, + }); + }); + + it('cancels a running job and ignores the watch afterwards', async () => { + const accepted = job({ id: 'job-c', state: 'queued', version: 1 }); + let release: (() => void) | null = null; + const gate = new Promise((resolve) => { + release = () => + resolve({ ...accepted, state: 'running', version: 3 }); + }); + const transport = new FakeTransport({ + edit: accepted, + polls: [ + { ...accepted, state: 'running', version: 2 }, + () => gate, + { ...accepted, state: 'done', version: 4 }, + ], + }); + const thread = new MapAiThread( + new FakeSession(MapDocument.load(OFFICE)), + 'zone-1', + { transport, sleep: no_sleep }, + ); + await thread.load(); + const submitting = thread.submit('recolour'); + await flush(20); + expect(thread.phase()).toMatchObject({ + kind: 'running', + cancelling: false, + }); + await thread.cancel(); + expect(transport.cancels).toHaveLength(1); + expect(thread.phase()).toMatchObject({ kind: 'cancelled' }); + expect(thread.announcement()).toBe(DEFAULT_AI_STRINGS.states.cancelled); + (release as unknown as () => void)(); + await submitting; + expect(thread.phase().kind).toBe('cancelled'); + expect(transport.job_polls).toHaveLength(2); + expect(thread.can_send()).toBe(true); + }); + + it('refines on top of a preview and abandons it on reset', async () => { + const { doc, transport, thread, sequence } = setup([ + recolour(MapDocument.load(OFFICE), 'area-1.01-status', '#d1fae5'), + ]); + await thread.load(); + await thread.submit('recolour'); + expect(thread.phase().kind).toBe('applied'); + const next = jobSequence({ + ops: [recolour(doc, 'area-1.02-status', '#fee')], + }); + transport.options.edit = next.accepted; + transport.polls = next.polls; + await thread.submit('and the other'); + expect(transport.resolves[0].body).toMatchObject({ + decision: 'refined', + accepted_op_ids: ['op-1'], + }); + expect(fill(doc, 'area-1.01-status')).toBe('#d1fae5'); + expect(fill(doc, 'area-1.02-status')).toBe('#fee'); + expect(thread.turns()[1]).toMatchObject({ + outcome: 'refined', + job_id: sequence.accepted.id, + }); + expect(thread.proposal()?.job.id).toBe(next.accepted.id); + + thread.reset(); + await flush(); + expect(transport.resolves[1].body).toMatchObject({ + decision: 'abandoned', + accepted_op_ids: ['op-1'], + }); + expect(thread.phase().kind).toBe('composing'); + expect(thread.state().parent_job_id).toBeNull(); + expect(fill(doc, 'area-1.02-status')).toBe('#fee'); + }); + + it('relays hover and selection from rows to the session', async () => { + const { doc, session, thread } = setup([ + recolour(MapDocument.load(OFFICE), 'area-1.01-status', '#d1fae5'), + ]); + await thread.load(); + await thread.submit('recolour'); + const row = thread.proposal()?.rows[0]; + if (!row) throw new Error('no row'); + thread.hoverRow(row); + expect(session.hover()).toBe( + doc.anchorOf(doc.byId('area-1.01-status') as Element), + ); + thread.hoverRow(null); + expect(session.hover()).toBeNull(); + thread.selectRow(row); + expect(session.selection()).toEqual(row.anchors); + }); +}); diff --git a/libs/map-editor/src/tests/apply.spec.ts b/libs/map-editor/src/tests/apply.spec.ts new file mode 100644 index 00000000000..8cd9cc208ab --- /dev/null +++ b/libs/map-editor/src/tests/apply.spec.ts @@ -0,0 +1,1155 @@ +import { applyOps, upsertStyleRule } from '../lib/apply'; +import { ANCHOR_ATTR } from '../lib/constants'; +import { AttributeGeometryProvider, NoGeometryProvider } from '../lib/geometry'; +import { MapDocument } from '../lib/map-document'; +import { MapOp, NameValue } from '../lib/ops'; +import { anchor, load, loadMap, OFFICE } from './fixtures'; + +const geometry = new AttributeGeometryProvider(); + +function office(): MapDocument { + return MapDocument.load(OFFICE, { geometry }); +} + +function one(doc: MapDocument, op: MapOp) { + const result = applyOps(doc, [op]); + return { result, applied: result.applied[0], rejected: result.rejected[0] }; +} + +describe('applyOps', () => { + describe('set_attr', () => { + it('refuses the editor attributes', () => { + const doc = office(); + expect( + one(doc, { + op: 'set_attr', + why: '', + target: { id: 'plant-2' }, + name: 'data-map-editor-anchor', + value: 'e1', + }).rejected?.reason, + ).toMatch(/not editable/); + expect( + one(doc, { + op: 'set_attr', + why: '', + target: { id: 'plant-2' }, + name: 'data-name', + value: 'Fern', + }).applied, + ).toBeDefined(); + }); + + it('sets the attribute and the inline style for fill and stroke', () => { + const doc = office(); + const { applied } = one(doc, { + op: 'set_attr', + why: '', + target: { id: 'table-01.001' }, + name: 'fill', + value: '#abc', + }); + const rect = doc.byId('table-01.001') as SVGElement; + expect(applied).toBeDefined(); + expect(rect.getAttribute('fill')).toBe('#abc'); + expect(rect.style.getPropertyValue('fill')).toBe('#abc'); + expect(applied.anchors).toEqual([anchor(doc, 'table-01.001')]); + }); + + it('removes the attribute and inline style for a null value', () => { + const doc = office(); + one(doc, { + op: 'set_attr', + why: '', + target: { id: 'table-01.001' }, + name: 'fill', + value: '#abc', + }); + one(doc, { + op: 'set_attr', + why: '', + target: { id: 'table-01.001' }, + name: 'fill', + value: null, + }); + const rect = doc.byId('table-01.001') as SVGElement; + expect(rect.hasAttribute('fill')).toBe(false); + expect(rect.style.getPropertyValue('fill')).toBe(''); + }); + + it('rejects attributes outside the allow-list and bad values', () => { + const doc = office(); + for (const name of [ + 'id', + 'transform', + 'href', + 'xlink:href', + 'style', + 'onclick', + 'display', + 'd', + ]) { + expect( + one(doc, { + op: 'set_attr', + why: '', + target: { id: 'bg' }, + name, + value: 'x', + }).rejected?.reason, + ).toMatch(/not editable/); + } + expect( + one(doc, { + op: 'set_attr', + why: '', + target: { id: 'bg' }, + name: 'fill', + value: '#f28c28.', + }).rejected?.reason, + ).toMatch(/not a colour/); + expect( + one(doc, { + op: 'set_attr', + why: '', + target: { id: 'bg' }, + name: 'opacity', + value: '2', + }).rejected?.reason, + ).toMatch(/opacity/); + expect( + one(doc, { + op: 'set_attr', + why: '', + target: { id: 'bg' }, + name: 'data-name', + value: 'Kitchen', + }).applied, + ).toBeDefined(); + expect(doc.byId('bg')?.getAttribute('data-name')).toBe('Kitchen'); + }); + + it('leaves every other element untouched', () => { + const doc = loadMap('level_1', { geometry }); + const before = doc.signatures(); + const target = doc.byId('table-01.004') as Element; + one(doc, { + op: 'set_attr', + why: '', + target: { id: 'table-01.004' }, + name: 'fill', + value: 'red', + }); + const after = doc.signatures(); + const changed = [...before] + .filter(([key, sig]) => after.get(key) !== sig) + .map(([key]) => key); + expect(changed).toEqual([doc.anchorOf(target)]); + }); + }); + + describe('set_style_rule', () => { + it('appends a rule and merges into an existing one', () => { + expect( + upsertStyleRule('.a{fill:#000}', '.b', { fill: '#fff' }), + ).toBe('.a{fill:#000}\n.b{fill:#fff}'); + expect( + upsertStyleRule( + '.a{fill:#000;stroke:#111}.ab{fill:#222}', + '.a', + { fill: '#fff' }, + ), + ).toBe('.a{fill:#fff;stroke:#111}.ab{fill:#222}'); + expect(upsertStyleRule('', '.a', { fill: '#fff' })).toBe( + '.a{fill:#fff}', + ); + }); + + it('matches whole selectors only and keeps !important on untouched declarations', () => { + expect( + upsertStyleRule('.foo .cls-1{fill:red !important}', '.cls-1', { + stroke: 'blue', + }), + ).toBe('.foo .cls-1{fill:red !important}\n.cls-1{stroke:blue}'); + expect( + upsertStyleRule('.a{fill:red !important;stroke:#111}', '.a', { + stroke: 'blue', + }), + ).toBe('.a{fill:red !important;stroke:blue}'); + expect( + upsertStyleRule('.b{fill:#000}\n.a{fill:#000}', '.a', { + fill: '#fff', + }), + ).toBe('.b{fill:#000}\n.a{fill:#fff}'); + }); + + it('edits the first style element and rejects unsafe or unknown properties', () => { + const doc = office(); + const { applied } = one(doc, { + op: 'set_style_rule', + why: '', + selector: '.desk', + declarations: [{ name: 'fill', value: '#eee' }], + }); + expect(applied).toBeDefined(); + expect(doc.root.querySelector('style')?.textContent).toContain( + '.desk{fill:#eee;stroke:#446}', + ); + expect( + one(doc, { + op: 'set_style_rule', + why: '', + selector: '*', + declarations: [{ name: 'fill', value: '#eee' }], + }).rejected, + ).toBeDefined(); + expect( + one(doc, { + op: 'set_style_rule', + why: '', + selector: '.desk', + declarations: [{ name: 'display', value: 'none' }], + }).rejected?.reason, + ).toMatch(/not editable/); + expect( + one(doc, { + op: 'set_style_rule', + why: '', + selector: '.desk', + declarations: [{ name: 'fill', value: 'url(http://x)' }], + }).rejected, + ).toBeDefined(); + expect( + one(doc, { + op: 'set_style_rule', + why: '', + selector: '.desk[', + declarations: [{ name: 'fill', value: '#fff' }], + }).rejected?.reason, + ).toMatch(/parse/); + expect( + one(doc, { + op: 'set_style_rule', + why: '', + selector: '.desk', + declarations: [ + { name: 5, value: 'x' } as unknown as NameValue, + ], + }).rejected?.reason, + ).toMatch(/not editable/); + }); + + it('creates a style element when the map has none', () => { + const doc = load(''); + const { applied } = one(doc, { + op: 'set_style_rule', + why: '', + selector: '#r', + declarations: [{ name: 'fill', value: 'red' }], + }); + expect(applied.created.length).toBe(1); + expect(doc.root.firstElementChild?.localName).toBe('style'); + }); + }); + + describe('set_text', () => { + it('drops the tspan lines of a text element and reports them as touched', () => { + const doc = office(); + const line = anchor(doc, 'sub-1'); + const { applied } = one(doc, { + op: 'set_text', + why: '', + target: { id: 'label-1' }, + text: 'Boardroom', + }); + const label = doc.byId('label-1') as Element; + expect(label.textContent).toBe('Boardroom'); + expect(label.querySelector('tspan')).toBeNull(); + expect(applied.touched).toContain(line); + }); + + it('replaces the text of text and tspan only', () => { + const doc = office(); + one(doc, { + op: 'set_text', + why: '', + target: { id: 'sub-1' }, + text: '(10 seats)', + }); + expect(doc.byId('sub-1')?.textContent).toBe('(10 seats)'); + expect( + one(doc, { + op: 'set_text', + why: '', + target: { id: 'bg' }, + text: 'x', + }).rejected?.reason, + ).toMatch(/text or tspan/); + }); + }); + + describe('transform', () => { + it('prepends a translate on a root child', () => { + const doc = office(); + one(doc, { + op: 'transform', + why: '', + target: { id: 'guide' }, + translate: [5, 10], + }); + expect(doc.byId('guide')?.getAttribute('transform')).toBe( + 'translate(5 10)', + ); + expect( + geometry.bboxOf(doc.byId('guide') as Element)?.slice(0, 2), + ).toEqual([5, 260]); + }); + + it('converts a root-space delta into the parent space through the inverse CTM', () => { + const doc = load( + ``, + { geometry }, + ); + one(doc, { + op: 'transform', + why: '', + target: { id: 'r' }, + translate: [10, 0], + }); + expect(doc.byId('r')?.getAttribute('transform')).toBe( + 'translate(5 0)', + ); + expect(geometry.bboxOf(doc.byId('r') as Element)).toEqual([ + 10, 0, 20, 20, + ]); + }); + + it('keeps the element transform and composes scale and rotate about an origin', () => { + const doc = load( + ``, + { geometry }, + ); + one(doc, { + op: 'transform', + why: '', + targets: [{ id: 'r' }], + scale: [2, 2], + origin: [0, 0], + }); + expect(doc.byId('r')?.getAttribute('transform')).toBe( + 'matrix(2 0 0 2 0 0) translate(1 1)', + ); + expect(geometry.bboxOf(doc.byId('r') as Element)).toEqual([ + 2, 2, 20, 20, + ]); + }); + + it('flags an unconverted transform when the parent CTM is unknown', () => { + const doc = load( + ``, + { geometry: new NoGeometryProvider() }, + ); + const { applied } = one(doc, { + op: 'transform', + why: '', + target: { id: 'r' }, + translate: [10, 0], + }); + expect(applied.note).toMatch(/parent transform unknown/); + expect(doc.byId('r')?.getAttribute('transform')).toBe( + 'translate(10 0)', + ); + }); + + it('rejects the root, empty specs and bad numbers', () => { + const doc = office(); + expect( + one(doc, { + op: 'transform', + why: '', + target: { a: 'e1' }, + translate: [1, 1], + }).rejected?.reason, + ).toMatch(/root/); + expect( + one(doc, { op: 'transform', why: '', target: { id: 'bg' } }) + .rejected?.reason, + ).toMatch(/no translate/); + expect( + one(doc, { + op: 'transform', + why: '', + target: { id: 'bg' }, + scale: [0, 1], + }).rejected?.reason, + ).toMatch(/scale/); + }); + }); + + describe('delete', () => { + it('removes elements with their subtrees and reports every removed anchor', () => { + const doc = office(); + const desks = doc.byId('desks') as Element; + const subtree = MapDocument.subtree(desks).map((e) => + doc.anchorOf(e), + ); + const { applied } = one(doc, { + op: 'delete', + why: '', + targets: [{ id: 'desks' }], + }); + expect(doc.byId('desks')).toBeNull(); + expect(applied.touched).toEqual(subtree); + }); + + it('refuses the root, defs and the only style', () => { + const doc = office(); + expect( + one(doc, { op: 'delete', why: '', target: { a: 'e1' } }) + .rejected?.reason, + ).toMatch(/root/); + const defs = doc.anchorOf( + doc.root.querySelector('defs') as Element, + ); + expect( + one(doc, { op: 'delete', why: '', target: { a: defs } }) + .rejected?.reason, + ).toMatch(/defs/); + const style = doc.anchorOf( + doc.root.querySelector('style') as Element, + ); + expect( + one(doc, { op: 'delete', why: '', target: { a: style } }) + .rejected?.reason, + ).toMatch(/style/); + expect( + one(doc, { op: 'delete', why: '' }).rejected?.reason, + ).toMatch(/no target/); + }); + }); + + describe('duplicate', () => { + it('copies with an offset per copy, strips descendant ids and follows an id pattern', () => { + const doc = office(); + const { applied } = one(doc, { + op: 'duplicate', + why: '', + target: { id: 'table-01.003' }, + translate: [40, 0], + count: 2, + id_pattern: 'table-01.{n:03}', + }); + expect(applied.created.length).toBe(2); + expect(doc.byId('table-01.004')?.getAttribute('transform')).toBe( + 'translate(40 0)', + ); + expect(doc.byId('table-01.005')?.getAttribute('transform')).toBe( + 'translate(80 0)', + ); + expect( + doc + .byId('table-01.005') + ?.previousElementSibling?.getAttribute('id'), + ).toBe('table-01.004'); + expect( + geometry.bboxOf(doc.byId('table-01.005') as Element), + ).toEqual([260, 200, 30, 20]); + }); + + it('strips ids inside the copy and converts the offset into the parent space', () => { + const doc = load( + ``, + { geometry }, + ); + const { applied } = one(doc, { + op: 'duplicate', + why: '', + target: { id: 'src' }, + translate: [10, 0], + new_id: 'copy', + }); + expect(applied.created.length).toBe(2); + const copy = doc.byId('copy') as Element; + expect(copy.querySelector('[id]')).toBeNull(); + expect(copy.getAttribute('transform')).toBe('translate(5 0)'); + expect(copy.getAttribute(ANCHOR_ATTR)).not.toBe( + doc.byId('src')?.getAttribute(ANCHOR_ATTR), + ); + expect(doc.byId('inner')).not.toBeNull(); + }); + + it('defaults to an ai- id and refuses collisions', () => { + const doc = office(); + one(doc, { + op: 'duplicate', + why: '', + target: { id: 'zone-01.A' }, + translate: [0, 10], + }); + expect(doc.byId('ai-1')).not.toBeNull(); + expect( + one(doc, { + op: 'duplicate', + why: '', + target: { id: 'zone-01.A' }, + translate: [0, 10], + new_id: 'bg', + }).rejected?.reason, + ).toMatch(/already exists/); + expect( + one(doc, { + op: 'duplicate', + why: '', + target: { id: 'zone-01.A' }, + translate: [0, 10], + new_id: '1bad', + }).rejected?.reason, + ).toMatch(/letter/); + expect( + one(doc, { + op: 'duplicate', + why: '', + target: { id: 'zone-01.A' }, + translate: [0, 10], + count: 41, + }).rejected?.reason, + ).toMatch(/count/); + }); + }); + + describe('insert_shape', () => { + it('refuses an attribute without a string name', () => { + const doc = office(); + expect( + one(doc, { + op: 'insert_shape', + why: '', + kind: 'rect', + attrs: [{ name: 5, value: 'x' } as unknown as NameValue], + }).rejected?.reason, + ).toMatch(/not allowed on a rect/); + }); + + it('keeps furniture visible under a new booking overlay copied from a solid room class', () => { + const doc = MapDocument.load( + ``, + { geometry }, + ); + const furniture = doc.byId('furniture')!.outerHTML; + const existing = doc.byId('area-existing-status')!.outerHTML; + const result = one(doc, { + op: 'insert_shape', + why: '', + kind: 'rect', + parent: { id: 'rooms' }, + new_id: 'area-new-status', + attrs: [ + { name: 'x', value: '10' }, + { name: 'y', value: '10' }, + { name: 'width', value: '40' }, + { name: 'height', value: '40' }, + { name: 'class', value: 'room' }, + ], + }); + expect(result.rejected).toBeUndefined(); + const overlay = doc.byId('area-new-status') as SVGElement; + expect(overlay.getAttribute('fill-opacity')).toBe('0.3'); + expect(overlay.style.getPropertyValue('fill-opacity')).toBe('0.3'); + expect(doc.byId('furniture')!.outerHTML).toBe(furniture); + expect(doc.byId('area-existing-status')!.outerHTML).toBe(existing); + one(doc, { + op: 'set_attr', + why: '', + target: { id: 'area-new-status' }, + name: 'fill-opacity', + value: '0.2', + }); + expect(overlay.style.getPropertyValue('fill-opacity')).toBe('0.2'); + }); + + it('preserves explicit overlay opacity and ordinary inserted artwork', () => { + const doc = office(); + for (const [id, attrs] of [ + [ + 'area-explicit-status', + [{ name: 'fill-opacity', value: '0.15' }], + ], + ['area-opacity-status', [{ name: 'opacity', value: '0.25' }]], + ['ai-art', []], + ] as const) { + one(doc, { + op: 'insert_shape', + why: '', + kind: 'rect', + new_id: id, + attrs: [ + { name: 'width', value: '10' }, + { name: 'height', value: '10' }, + ...attrs, + ], + }); + } + expect( + ( + doc.byId('area-explicit-status') as SVGElement + ).style.getPropertyValue('fill-opacity'), + ).toBe('0.15'); + expect( + ( + doc.byId('area-opacity-status') as SVGElement + ).style.getPropertyValue('opacity'), + ).toBe('0.25'); + expect(doc.byId('ai-art')!.hasAttribute('fill-opacity')).toBe( + false, + ); + }); + + it('inserts into the root with an ai- id and into a group after a sibling', () => { + const doc = office(); + const first = one(doc, { + op: 'insert_shape', + why: '', + kind: 'rect', + attrs: [ + { name: 'x', value: '10' }, + { name: 'y', value: '10' }, + { name: 'width', value: '5' }, + { name: 'height', value: '5' }, + { name: 'fill', value: 'red' }, + ], + }); + expect(first.applied.created.length).toBe(1); + expect(doc.root.lastElementChild?.getAttribute('id')).toBe('ai-1'); + const second = one(doc, { + op: 'insert_shape', + why: '', + parent: { id: 'rooms' }, + after: { id: 'area-1.01-status' }, + kind: 'circle', + attrs: [ + { name: 'cx', value: '1' }, + { name: 'cy', value: '1' }, + { name: 'r', value: '1' }, + ], + new_id: 'area-1.03-status', + }); + expect(second.applied).toBeDefined(); + expect( + doc + .byId('area-1.01-status') + ?.nextElementSibling?.getAttribute('id'), + ).toBe('area-1.03-status'); + }); + + it('carries the inverse parent transform so root units stay what was asked', () => { + const doc = office(); + one(doc, { + op: 'insert_shape', + why: '', + parent: { id: 'desks' }, + kind: 'rect', + attrs: [ + { name: 'x', value: '220' }, + { name: 'y', value: '200' }, + { name: 'width', value: '30' }, + { name: 'height', value: '20' }, + ], + new_id: 'table-01.004', + }); + const rect = doc.byId('table-01.004') as Element; + expect(rect.getAttribute('transform')).toBe('translate(-100 -200)'); + expect(geometry.bboxOf(rect)).toEqual([220, 200, 30, 20]); + }); + + it('rejects bad kinds, parents, attributes and path data', () => { + const doc = office(); + expect( + one(doc, { + op: 'insert_shape', + why: '', + kind: 'script' as 'rect', + attrs: [], + }).rejected?.reason, + ).toMatch(/cannot be inserted/); + expect( + one(doc, { + op: 'insert_shape', + why: '', + parent: { id: 'bg' }, + kind: 'rect', + attrs: [], + }).rejected?.reason, + ).toMatch(/parent/); + expect( + one(doc, { + op: 'insert_shape', + why: '', + kind: 'rect', + attrs: [{ name: 'onclick', value: 'x' }], + }).rejected?.reason, + ).toMatch(/not allowed/); + expect( + one(doc, { + op: 'insert_shape', + why: '', + kind: 'rect', + attrs: [{ name: 'href', value: '#a' }], + }).rejected?.reason, + ).toMatch(/not allowed/); + expect( + one(doc, { + op: 'insert_shape', + why: '', + kind: 'rect', + attrs: [{ name: 'x', value: 'ten' }], + }).rejected?.reason, + ).toMatch(/not a number/); + expect( + one(doc, { + op: 'insert_shape', + why: '', + kind: 'path', + attrs: [], + }).rejected?.reason, + ).toMatch(/needs d/); + expect( + one(doc, { + op: 'insert_shape', + why: '', + kind: 'path', + attrs: [{ name: 'd', value: 'L1 1' }], + }).rejected?.reason, + ).toMatch(/start with M/); + expect( + one(doc, { + op: 'insert_shape', + why: '', + kind: 'text', + attrs: [{ name: 'x', value: '1' }], + text: 'Hi', + }).applied, + ).toBeDefined(); + expect(doc.root.lastElementChild?.textContent).toBe('Hi'); + }); + }); + + describe('set_id', () => { + it('renames and rewrites url(#old) and href="#old" references', () => { + const doc = office(); + const { applied } = one(doc, { + op: 'set_id', + why: '', + target: { id: 'table-01.001' }, + new_id: 'table-01.010', + }); + expect(doc.byId('table-01.001')).toBeNull(); + expect(doc.byId('table-01.010')).not.toBeNull(); + expect(doc.byId('ghost')?.getAttribute('xlink:href')).toBe( + '#table-01.010', + ); + expect(applied.touched).toContain(anchor(doc, 'ghost')); + one(doc, { + op: 'set_id', + why: '', + target: { id: 'arrow' }, + new_id: 'arrowhead', + }); + expect(doc.byId('guide')?.getAttribute('marker-end')).toBe( + 'url(#arrowhead)', + ); + one(doc, { + op: 'set_id', + why: '', + target: { id: 'grad' }, + new_id: 'gradient', + }); + expect(doc.byId('bg')?.getAttribute('fill')).toBe('url(#gradient)'); + }); + + it('refuses duplicates and bad ids, and removes an id for null', () => { + const doc = office(); + expect( + one(doc, { + op: 'set_id', + why: '', + target: { id: 'bg' }, + new_id: 'desks', + }).rejected?.reason, + ).toMatch(/already exists/); + expect( + one(doc, { + op: 'set_id', + why: '', + target: { id: 'bg' }, + new_id: '2nd', + }).rejected?.reason, + ).toMatch(/letter/); + expect( + one(doc, { + op: 'set_id', + why: '', + target: { id: 'bg' }, + new_id: 'a b', + }).rejected?.reason, + ).toMatch(/only contain/); + const { applied } = one(doc, { + op: 'set_id', + why: '', + target: { id: 'table-01.001' }, + new_id: null, + }); + expect(doc.byId('table-01.001')).toBeNull(); + expect(applied.note).toMatch(/dangle/); + }); + }); + + describe('reorder', () => { + it('moves among siblings', () => { + const doc = office(); + const ids = () => + Array.from((doc.byId('desks') as Element).children).map((c) => + c.getAttribute('id'), + ); + one(doc, { + op: 'reorder', + why: '', + target: { id: 'table-01.003' }, + position: 'first', + }); + expect(ids()).toEqual([ + 'table-01.003', + 'table-01.001', + 'table-01.002', + ]); + one(doc, { + op: 'reorder', + why: '', + target: { id: 'table-01.003' }, + after: { id: 'table-01.001' }, + }); + expect(ids()).toEqual([ + 'table-01.001', + 'table-01.003', + 'table-01.002', + ]); + one(doc, { + op: 'reorder', + why: '', + target: { id: 'table-01.001' }, + before: { id: 'table-01.002' }, + }); + expect(ids()).toEqual([ + 'table-01.003', + 'table-01.001', + 'table-01.002', + ]); + one(doc, { + op: 'reorder', + why: '', + target: { id: 'table-01.003' }, + position: 'last', + }); + expect(ids()).toEqual([ + 'table-01.001', + 'table-01.002', + 'table-01.003', + ]); + expect( + one(doc, { + op: 'reorder', + why: '', + target: { id: 'table-01.003' }, + before: { id: 'bg' }, + }).rejected?.reason, + ).toMatch(/siblings/); + expect( + one(doc, { + op: 'reorder', + why: '', + target: { id: 'table-01.003' }, + }).rejected?.reason, + ).toMatch(/exactly one/); + }); + }); + + describe('group and ungroup', () => { + it('wraps siblings in a new group and lifts them back out with the transform folded in', () => { + const doc = office(); + const { applied } = one(doc, { + op: 'group', + why: '', + targets: [{ id: 'table-01.002' }, { id: 'table-01.001' }], + new_id: 'pod-1', + }); + const pod = doc.byId('pod-1') as Element; + expect(applied.created).toEqual([doc.anchorOf(pod)]); + expect(pod.parentElement?.getAttribute('id')).toBe('desks'); + expect( + Array.from(pod.children).map((c) => c.getAttribute('id')), + ).toEqual(['table-01.001', 'table-01.002']); + expect(pod.previousElementSibling).toBeNull(); + expect( + one(doc, { + op: 'group', + why: '', + targets: [{ id: 'bg' }, { id: 'table-01.003' }], + }).rejected?.reason, + ).toMatch(/share one parent/); + const ungrouped = one(doc, { + op: 'ungroup', + why: '', + target: { id: 'desks' }, + }); + expect(ungrouped.applied).toBeDefined(); + expect(doc.byId('desks')).toBeNull(); + expect(pod.getAttribute('transform')).toBe('translate(100 200)'); + expect(doc.byId('table-01.003')?.getAttribute('transform')).toBe( + 'translate(100 200)', + ); + expect( + geometry.bboxOf(doc.byId('table-01.003') as Element), + ).toEqual([180, 200, 30, 20]); + expect( + one(doc, { op: 'ungroup', why: '', target: { id: 'bg' } }) + .rejected?.reason, + ).toMatch(/needs a /); + }); + }); + + describe('replace_element', () => { + it('swaps in sanitised markup, keeps the id and anchor and assigns fresh descendant anchors', () => { + const doc = office(); + const before = anchor(doc, 'area-1.02-status'); + const { applied } = one(doc, { + op: 'replace_element', + why: '', + target: { id: 'area-1.02-status' }, + markup: 'Room', + }); + expect(applied).toBeDefined(); + const replaced = doc.byId('area-1.02-status') as Element; + expect(replaced.localName).toBe('g'); + expect(doc.anchorOf(replaced)).toBe(before); + expect(replaced.hasAttribute('onclick')).toBe(false); + expect(replaced.querySelector('script')).toBeNull(); + expect(replaced.querySelector('title')?.textContent).toBe('Room'); + expect(applied.created.length).toBe(2); + expect(applied.note).toMatch(/ignored/); + expect(doc.byId('other')).toBeNull(); + }); + + it('rejects more than one element, oversized markup, the root and id collisions', () => { + const doc = office(); + expect( + one(doc, { + op: 'replace_element', + why: '', + target: { id: 'bg' }, + markup: '', + }).rejected?.reason, + ).toMatch(/exactly one/); + expect( + one(doc, { + op: 'replace_element', + why: '', + target: { a: 'e1' }, + markup: '', + }).rejected?.reason, + ).toMatch(/root/); + expect( + one(doc, { + op: 'replace_element', + why: '', + target: { id: 'bg' }, + markup: ``, + }).rejected?.reason, + ).toMatch(/KB/); + expect( + one(doc, { + op: 'replace_element', + why: '', + target: { id: 'bg' }, + markup: '', + }).rejected?.reason, + ).toMatch(/already exists/); + expect( + one(doc, { + op: 'replace_element', + why: '', + target: { id: 'bg' }, + markup: '', + }).rejected, + ).toBeDefined(); + expect( + one(doc, { + op: 'replace_element', + why: '', + target: { id: 'bg' }, + markup: '', + }).rejected?.reason, + ).toMatch(/ + + + + + + + Meeting 1.01(8 seats) + + + `, +); diff --git a/libs/map-editor/src/tests/fixtures/map-ai-context.json b/libs/map-editor/src/tests/fixtures/map-ai-context.json new file mode 100644 index 00000000000..b728c4cee3a --- /dev/null +++ b/libs/map-editor/src/tests/fixtures/map-ai-context.json @@ -0,0 +1,163 @@ +{ + "schema": "placeos.map-ai-context/1", + "viewBox": [0, 0, 1725.26, 619], + "image_px": [2048, 735], + "units_per_px": 0.8424, + "protected_ids": ["table-01.004", "area-1.02-status"], + "duplicate_ids": ["table-01.015"], + "index": [ + { + "a": "e1", + "id": null, + "tag": "g", + "cls": null, + "label": "walls", + "bbox": [0, 0, 1725.26, 619], + "p": null, + "z": 1, + "kind": "layer-group", + "prot": false, + "h": "a1a1a1a1", + "fill": null, + "in": null, + "piece": null + }, + { + "a": "e2", + "id": "area-1.01-status", + "tag": "path", + "cls": "cls-3", + "label": "Meeting 1.01", + "bbox": [100, 80, 120, 90], + "p": "e1", + "z": 2, + "kind": "zone", + "prot": false, + "h": "b2b2b2b2", + "fill": null, + "in": null, + "piece": null + }, + { + "a": "e3", + "id": "area-1.02-status", + "tag": "path", + "cls": "cls-3", + "label": "Meeting 1.02", + "bbox": [240, 80, 120, 90], + "p": "e1", + "z": 3, + "kind": "zone", + "prot": true, + "h": "c3c3c3c3", + "fill": null, + "in": null, + "piece": null + }, + { + "a": "e4", + "id": "table-01.004", + "tag": "rect", + "cls": "cls-12", + "label": "Desk 01.004", + "bbox": [640, 420, 30, 20], + "p": "e1", + "z": 4, + "kind": "bookable", + "prot": true, + "h": "d4d4d4d4", + "fill": null, + "in": null, + "piece": null + }, + { + "a": "e5", + "id": "table-01.005", + "tag": "rect", + "cls": "cls-12", + "label": "Desk 01.005", + "bbox": [680, 420, 30, 20], + "p": "e1", + "z": 5, + "kind": "bookable", + "prot": false, + "h": "e5e5e5e5", + "fill": null, + "in": null, + "piece": null + }, + { + "a": "e6", + "id": "label-1.01", + "tag": "text", + "cls": null, + "label": "Meeting 1.01", + "bbox": [110, 60, 60, 12], + "p": "e1", + "z": 6, + "kind": "art", + "prot": false, + "h": "f6f6f6f6", + "fill": null, + "in": null, + "piece": null + }, + { + "a": "e7", + "id": null, + "tag": "path", + "cls": "cls-1", + "label": null, + "bbox": [0, 0, 1725.26, 4], + "p": "e1", + "z": 7, + "kind": "art", + "prot": false, + "h": "07070707", + "fill": null, + "in": null, + "piece": null + } + ], + "labels": [], + "shortlist": ["e2", "e4"], + "subtrees": { + "e2": "", + "e4": "" + }, + "style_rules": [ + ".cls-1{fill:none;stroke:#231f20}", + ".cls-3{fill:#fff}", + ".cls-12{fill:#d1d3d4}" + ], + "markup": { + "schema": "placeos.map-markup/1", + "viewBox": [0, 0, 1725.26, 619], + "annotations": [ + { + "n": 1, + "kind": "box", + "x": 630, + "y": 410, + "width": 90, + "height": 40, + "hits": ["e4", "e5"], + "near": ["e7"] + }, + { + "n": 2, + "kind": "pin", + "x": 160, + "y": 120, + "text": "this room", + "hits": ["e2"] + } + ], + "selected": ["area-1.01-status"] + }, + "stats": { + "elements": 748, + "ids": 84, + "bytes": 134861 + } +} diff --git a/libs/map-editor/src/tests/geometry.spec.ts b/libs/map-editor/src/tests/geometry.spec.ts new file mode 100644 index 00000000000..d75ed0b3f76 --- /dev/null +++ b/libs/map-editor/src/tests/geometry.spec.ts @@ -0,0 +1,165 @@ +import { + AttributeGeometryProvider, + localPoints, + NoGeometryProvider, +} from '../lib/geometry'; +import { MapDocument } from '../lib/map-document'; +import { + changeOfBasis, + composeTransform, + formatTransform, + invert, + multiply, + parseTransform, + translation, +} from '../lib/matrix'; +import { checkPathData, pathPoints } from '../lib/path-data'; +import { load, OFFICE } from './fixtures'; + +describe('matrix', () => { + it('parses transform lists in order', () => { + const m = parseTransform('translate(10 20) scale(2)'); + expect(m).toEqual({ a: 2, b: 0, c: 0, d: 2, e: 10, f: 20 }); + const r = parseTransform('rotate(90 5 5)'); + expect(r.a).toBeCloseTo(0); + expect(r.b).toBeCloseTo(1); + expect(r.e).toBeCloseTo(10); + expect(r.f).toBeCloseTo(0); + expect(parseTransform('matrix(1,0,0,1,5,6)')).toEqual({ + a: 1, + b: 0, + c: 0, + d: 1, + e: 5, + f: 6, + }); + expect(parseTransform('bogus(1) translate(1)')).toEqual({ + a: 1, + b: 0, + c: 0, + d: 1, + e: 1, + f: 0, + }); + }); + + it('formats translations and matrices', () => { + expect(formatTransform(translation(1.23456, -0))).toBe( + 'translate(1.235 0)', + ); + expect(formatTransform({ a: 2, b: 0, c: 0, d: 2, e: 1, f: 1 })).toBe( + 'matrix(2 0 0 2 1 1)', + ); + }); + + it('inverts and composes', () => { + const m = parseTransform('translate(10 20) scale(2)'); + const inverse = invert(m) as NonNullable>; + const identity = multiply(m, inverse); + expect(identity.a).toBeCloseTo(1); + expect(identity.e).toBeCloseTo(0); + expect(invert({ a: 0, b: 0, c: 0, d: 0, e: 0, f: 0 })).toBeNull(); + }); + + it('changes basis so a root-space translate becomes a parent-space one', () => { + const parent = parseTransform('scale(2)'); + const converted = changeOfBasis(translation(10, 0), parent); + expect(converted?.e).toBeCloseTo(5); + }); + + it('composes a transform op about an origin', () => { + const m = composeTransform({ + scale: [2, 2], + origin: [10, 10], + translate: [1, 0], + }); + expect(m).toEqual({ a: 2, b: 0, c: 0, d: 2, e: -9, f: -10 }); + }); +}); + +describe('path data', () => { + it('checks the grammar of path data', () => { + expect(checkPathData('M0 0 L10 10 Z').ok).toBe(true); + expect(checkPathData('M0,0 c1,1 2,2 3,3 h5 v5 z').ok).toBe(true); + expect(checkPathData('L10 10').ok).toBe(false); + expect(checkPathData('M0 0 L10').ok).toBe(false); + expect(checkPathData('M0 0 X10 10').ok).toBe(false); + expect(checkPathData('').ok).toBe(false); + }); + + it('collects absolute end and control points', () => { + expect(pathPoints('M10 10 l5 0 v5 H0 Z')).toEqual([ + [10, 10], + [15, 10], + [15, 15], + [0, 15], + ]); + expect(pathPoints('M0 0 A5 5 0 0 1 10 10')).toEqual([ + [0, 0], + [10, 10], + ]); + }); +}); + +describe('AttributeGeometryProvider', () => { + const geometry = new AttributeGeometryProvider(); + + it('boxes shapes from their attributes', () => { + const doc = load( + ` + + `, + ); + expect(geometry.bboxOf(doc.byId('r') as Element)).toEqual([ + 10, 20, 30, 40, + ]); + expect(geometry.bboxOf(doc.byId('c') as Element)).toEqual([ + 45, 45, 10, 10, + ]); + expect(geometry.bboxOf(doc.byId('l') as Element)).toEqual([ + 0, 0, 10, 5, + ]); + expect(geometry.bboxOf(doc.byId('p') as Element)).toEqual([ + 0, 0, 10, 10, + ]); + expect(geometry.bboxOf(doc.byId('d') as Element)).toEqual([ + 100, 100, 50, 20, + ]); + expect( + localPoints(doc.root.querySelector('style') || doc.root), + ).toEqual([]); + }); + + it('maps through ancestor transforms into root space', () => { + const doc = load( + ``, + ); + expect(geometry.bboxOf(doc.byId('r') as Element)).toEqual([ + 110, 60, 20, 20, + ]); + const ctm = geometry.ctmOf(doc.byId('r') as Element); + expect(ctm).toEqual({ a: 2, b: 0, c: 0, d: 2, e: 100, f: 50 }); + }); + + it('unions children for groups and skips non-rendered content', () => { + const doc = MapDocument.load(OFFICE); + const desks = geometry.bboxOf(doc.byId('desks') as Element); + expect(desks).toEqual([100, 200, 110, 20]); + expect( + geometry.bboxOf(doc.root.querySelector('defs') as Element), + ).toBeNull(); + const root = geometry.bboxOf(doc.root); + expect(root?.[0]).toBe(0); + expect(root?.[2]).toBe(1000); + }); + + it('answers null for a detached element and for the null provider', () => { + const doc = load(''); + const rect = doc.byId('r') as Element; + rect.remove(); + expect(geometry.ctmOf(rect)).toBeNull(); + const none = new NoGeometryProvider(); + expect(none.bboxOf()).toBeNull(); + expect(none.ctmOf()).toBeNull(); + }); +}); diff --git a/libs/map-editor/src/tests/history.spec.ts b/libs/map-editor/src/tests/history.spec.ts new file mode 100644 index 00000000000..be2b3a7c5a7 --- /dev/null +++ b/libs/map-editor/src/tests/history.spec.ts @@ -0,0 +1,67 @@ +import { MapHistory } from '../lib/history'; +import { MapDocument } from '../lib/map-document'; +import { anchor, OFFICE } from './fixtures'; + +describe('MapHistory', () => { + it('undoes and redoes whole-root snapshots with anchors intact', () => { + const doc = MapDocument.load(OFFICE); + const history = new MapHistory(doc); + const before = doc.signatures(); + const table_anchor = anchor(doc, 'table-01.001'); + history.push('recolour'); + (doc.byId('table-01.001') as Element).setAttribute('fill', 'red'); + history.push('delete'); + (doc.byId('zone-01.A') as Element).remove(); + expect(history.length).toBe(2); + expect(history.labels()).toEqual(['recolour', 'delete']); + expect(history.undo()).toBe(true); + expect(doc.byId('zone-01.A')).not.toBeNull(); + expect(doc.byId('table-01.001')?.getAttribute('fill')).toBe('red'); + expect(history.undo()).toBe(true); + expect(doc.byId('table-01.001')?.getAttribute('fill')).toBeNull(); + expect(doc.signatures()).toEqual(before); + expect(anchor(doc, 'table-01.001')).toBe(table_anchor); + expect(doc.byAnchor(table_anchor)).toBe(doc.byId('table-01.001')); + expect(history.undo()).toBe(false); + expect(history.redo()).toBe(true); + expect(doc.byId('table-01.001')?.getAttribute('fill')).toBe('red'); + expect(history.redo()).toBe(true); + expect(doc.byId('zone-01.A')).toBeNull(); + expect(history.redo()).toBe(false); + }); + + it('clears redo on push and caps the depth', () => { + const doc = MapDocument.load(OFFICE); + const history = new MapHistory(doc, { depth: 3 }); + for (let i = 0; i < 5; i++) history.push(`step ${i}`); + expect(history.length).toBe(3); + expect(history.labels()).toEqual(['step 2', 'step 3', 'step 4']); + history.undo(); + expect(history.can_redo).toBe(true); + history.push('new'); + expect(history.can_redo).toBe(false); + expect(history.redo_length).toBe(0); + }); + + it('drops the oldest entries to stay inside the byte budget but keeps one', () => { + const doc = MapDocument.load(OFFICE); + const one_snapshot = new XMLSerializer().serializeToString( + doc.root, + ).length; + const history = new MapHistory(doc, { + byte_budget: one_snapshot * 2.5, + }); + history.push('a'); + history.push('b'); + history.push('c'); + expect(history.length).toBe(2); + expect(history.labels()).toEqual(['b', 'c']); + expect(history.position()).toBe(3); + history.undo(); + expect(history.position()).toBe(2); + expect(history.bytes).toBeLessThanOrEqual(one_snapshot * 2.5); + const tiny = new MapHistory(doc, { byte_budget: 10 }); + tiny.push('only'); + expect(tiny.length).toBe(1); + }); +}); diff --git a/libs/map-editor/src/tests/map-document.spec.ts b/libs/map-editor/src/tests/map-document.spec.ts new file mode 100644 index 00000000000..d486a4e37b7 --- /dev/null +++ b/libs/map-editor/src/tests/map-document.spec.ts @@ -0,0 +1,249 @@ +import { ANCHOR_ATTR } from '../lib/constants'; +import { duplicateIds } from '../lib/ids'; +import { + MapDocument, + MapDocumentError, + parseViewBox, +} from '../lib/map-document'; +import { + anchor, + load, + loadMap, + OFFICE, + readMap, + SAMPLE_MAPS, + svg, +} from './fixtures'; + +function expectRejection(text: string, code: string) { + let error: unknown; + try { + MapDocument.load(text); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(MapDocumentError); + expect((error as MapDocumentError).code).toBe(code); +} + +describe('MapDocument.load', () => { + it('rejects malformed XML with a parse error', () => { + expectRejection( + '', + 'parse_error', + ); + }); + + it('rejects an empty document', () => { + expectRejection(' ', 'empty'); + }); + + it('rejects a DOCTYPE and entity declarations', () => { + expectRejection( + ']>&x;', + 'doctype', + ); + expectRejection( + '', + 'doctype', + ); + }); + + it('rejects a root that is not svg in the SVG namespace', () => { + expectRejection( + '', + 'not_svg', + ); + expectRejection('', 'not_svg'); + expectRejection(readMap('test'), 'not_svg'); + }); + + it('rejects a non-numeric viewBox', () => { + expectRejection(svg('', 'viewBox="0 0 100% 50"'), 'no_viewbox'); + expectRejection(svg('', 'viewBox="0 0 100"'), 'no_viewbox'); + expectRejection(svg('', 'viewBox="0 0 0 50"'), 'no_viewbox'); + }); + + it('synthesises a viewBox once from numeric unitless width and height', () => { + const doc = load('', {}, 'width="800" height="600"'); + expect(doc.view_box).toEqual({ x: 0, y: 0, width: 800, height: 600 }); + expect(doc.view_box_synthesised).toBe(true); + expect(doc.root.getAttribute('viewBox')).toBe('0 0 800 600'); + }); + + it('rejects a missing viewBox when width or height carry units', () => { + expectRejection( + svg('', 'width="100%" height="600"'), + 'no_viewbox', + ); + expectRejection( + svg('', 'width="10cm" height="6cm"'), + 'no_viewbox', + ); + expectRejection(svg('', ''), 'no_viewbox'); + }); + + it('parses viewBox values with commas and decimals', () => { + expect(parseViewBox('0,0,2507.883,1523.289')).toEqual({ + x: 0, + y: 0, + width: 2507.883, + height: 1523.289, + }); + expect(parseViewBox('a b c d')).toBeNull(); + }); + + it('keeps whether the source had an XML prolog', () => { + const with_prolog = MapDocument.load( + `\n${svg('')}`, + ); + expect(with_prolog.had_prolog).toBe(true); + expect( + with_prolog + .serialise() + .startsWith('\n'); + expect(without.had_prolog).toBe(false); + expect(without.serialise().startsWith(' { + const doc = load( + `
    + + `, + ); + const reasons = doc.sanitise_report.removed.map( + (entry) => `${entry.kind}:${entry.name || entry.tag}`, + ); + expect(reasons).toContain('element:script'); + expect(reasons).toContain('attribute:onclick'); + expect(reasons).toContain('element:foreignobject'); + expect(reasons).toContain('element:animate'); + expect(reasons).toContain('attribute:href'); + expect( + doc.root.querySelector('script, foreignObject, animate'), + ).toBeNull(); + expect(doc.root.querySelector('rect')?.hasAttribute('onclick')).toBe( + false, + ); + expect(doc.root.querySelector('use')?.hasAttribute('href')).toBe(false); + expect(doc.root.querySelector('image')?.getAttribute('href')).toMatch( + /^data:image\/png/, + ); + const css = doc.root.querySelector('style')?.textContent || ''; + expect(css).not.toContain('@import'); + expect(css).not.toContain('http://evil'); + expect(css).toContain('url(#grad)'); + }); + + it('assigns anchors in document order to every element', () => { + const doc = load( + 'x', + ); + expect(doc.anchorOf(doc.root)).toBe('e1'); + expect(anchor(doc, 'a')).toBe('e2'); + expect(anchor(doc, 'b')).toBe('e3'); + expect(anchor(doc, 'c')).toBe('e4'); + expect(anchor(doc, 'd')).toBe('e5'); + expect(doc.elementCount()).toBe(5); + }); + + it('resolves targets by anchor, id or both and rejects disagreement', () => { + const doc = load(''); + expect(doc.resolve({ a: 'e2' }).ok).toBe(true); + expect(doc.resolve({ id: 'b' }).ok).toBe(true); + expect(doc.resolve({ a: 'e2', id: 'a' }).ok).toBe(true); + const mismatch = doc.resolve({ a: 'e2', id: 'b' }); + expect(mismatch.ok).toBe(false); + expect(doc.resolve({ a: 'e99' }).ok).toBe(false); + expect(doc.resolve({ id: 'nope' }).ok).toBe(false); + expect(doc.resolve({}).ok).toBe(false); + expect(doc.resolve(null).ok).toBe(false); + }); + + it('hashes the canonical own-signature and ignores editor attributes', () => { + const doc = load(''); + const rect = doc.byId('a') as Element; + const hash = doc.hashOf(rect); + expect(hash).toMatch(/^[0-9a-f]{8}$/); + rect.setAttribute('data-map-editor-highlight', 'true'); + expect(doc.hashOf(rect)).toBe(hash); + rect.setAttribute('fill', 'blue'); + expect(doc.hashOf(rect)).not.toBe(hash); + }); + + it('serialises without editor attributes and keeps namespaces', () => { + const doc = MapDocument.load(OFFICE); + const markup = doc.serialise(); + expect(markup).not.toContain(ANCHOR_ATTR); + expect(markup).toContain('xmlns="http://www.w3.org/2000/svg"'); + expect(markup).toContain('xlink:href="#table-01.001"'); + expect(markup).toContain(' element.hasAttribute(ANCHOR_ATTR)), + ).toBe(true); + }); + + it('clones with the same anchors and an independent tree', () => { + const doc = MapDocument.load(OFFICE); + const copy = doc.clone(); + expect(copy.elementCount()).toBe(doc.elementCount()); + expect(anchor(copy, 'table-01.002')).toBe(anchor(doc, 'table-01.002')); + (copy.byId('table-01.002') as Element).setAttribute('fill', 'red'); + expect(doc.byId('table-01.002')?.getAttribute('fill')).toBeNull(); + expect(copy.nextAnchor()).toBe(doc.nextAnchor()); + }); + + it('swaps the root in place and re-indexes anchors', () => { + const doc = MapDocument.load(OFFICE); + const snapshot = doc.root.cloneNode(true) as Element; + (doc.byId('table-01.001') as Element).remove(); + expect(doc.byId('table-01.001')).toBeNull(); + doc.replaceRoot(snapshot); + expect(doc.byId('table-01.001')).not.toBeNull(); + expect(doc.byAnchor(anchor(doc, 'table-01.001'))).toBe( + doc.byId('table-01.001'), + ); + expect(doc.root.ownerDocument.documentElement).toBe(doc.root); + }); + + describe.each(SAMPLE_MAPS)('sample map %s', (name) => { + it('loads, round-trips with equal signatures and stable anchors, and strips nothing', () => { + const doc = loadMap(name); + expect(doc.sanitise_report.removed).toEqual([]); + expect(doc.elementCount()).toBeGreaterThan(20); + const before = doc.signatures(); + const reloaded = MapDocument.load(doc.serialise()); + expect(reloaded.elementCount()).toBe(doc.elementCount()); + const after = reloaded.signatures(); + let differences = 0; + for (const [key, signature] of before) { + if (after.get(key) !== signature) differences++; + } + expect(differences).toBe(0); + const duplicates = duplicateIds(doc.root); + for (const element of doc.elements()) { + const id = element.getAttribute('id'); + if (!id || duplicates.includes(id)) continue; + expect(reloaded.anchorOf(reloaded.byId(id) as Element)).toBe( + doc.anchorOf(element), + ); + } + expect(reloaded.had_prolog).toBe(doc.had_prolog); + expect(reloaded.view_box).toEqual(doc.view_box); + }); + + it('serialises within a few bytes of the source', () => { + const doc = loadMap(name); + const source = readMap(name); + expect( + Math.abs(doc.serialise().length - source.length), + ).toBeLessThan(200); + }); + }); +}); diff --git a/libs/map-editor/src/tests/map-index.spec.ts b/libs/map-editor/src/tests/map-index.spec.ts new file mode 100644 index 00000000000..168a579d031 --- /dev/null +++ b/libs/map-editor/src/tests/map-index.spec.ts @@ -0,0 +1,125 @@ +import { AttributeGeometryProvider } from '../lib/geometry'; +import { MapDocument } from '../lib/map-document'; +import { buildIndex, promptLines } from '../lib/map-index'; +import { ProtectedIdSet } from '../lib/protected-ids'; +import { anchor, loadMap, OFFICE, SAMPLE_MAPS } from './fixtures'; + +const geometry = new AttributeGeometryProvider(); + +describe('buildIndex', () => { + it('describes every element with anchors, parents, boxes, styles and kinds', () => { + const protected_ids = new ProtectedIdSet([ + { id: 'table-01.001', kind: 'desk', label: 'Desk 1' }, + ]); + const doc = MapDocument.load(OFFICE, { + geometry, + protected: protected_ids, + }); + const index = buildIndex(doc); + expect(index.entries.length).toBe(doc.elementCount()); + expect(index.entries[0].kind).toBe('root'); + const desk = index.by_anchor.get(anchor(doc, 'table-01.001'))!; + expect(desk).toMatchObject({ + id: 'table-01.001', + tag: 'rect', + classes: ['desk'], + protected: true, + kind: 'bookable', + depth: 2, + visible: true, + }); + expect(desk.bbox).toEqual([100, 200, 30, 20]); + expect(desk.parent).toBe(anchor(doc, 'desks')); + expect(desk.fill).toBe('#dde'); + expect(desk.stroke).toBe('#446'); + expect(desk.hash).toMatch(/^[0-9a-f]{8}$/); + expect(index.by_anchor.get(anchor(doc, 'table-01.002'))?.kind).toBe( + 'bookable', + ); + expect(index.by_anchor.get(anchor(doc, 'base'))?.kind).toBe( + 'layer-group', + ); + expect(index.by_anchor.get(anchor(doc, 'zone-01.A'))?.kind).toBe( + 'zone', + ); + expect(index.by_anchor.get(anchor(doc, 'label-1'))?.kind).toBe('text'); + expect(index.by_anchor.get(anchor(doc, 'label-1'))?.text).toBe( + 'Meeting 1.01(8 seats)', + ); + expect(index.by_anchor.get(anchor(doc, 'grad'))?.kind).toBe( + 'non-rendered', + ); + expect(index.by_anchor.get(anchor(doc, 'grad'))?.z).toBeNull(); + expect(index.by_anchor.get(anchor(doc, 'base'))?.children).toEqual({ + path: 1, + }); + expect(index.style_rules.length).toBe(3); + expect(index.stats.ids).toBe(21); + }); + + it('writes the prompt lines in the spike shape', () => { + const doc = MapDocument.load(OFFICE, { geometry }); + const text = promptLines(buildIndex(doc)); + const lines = text.split('\n'); + expect(lines[0]).toBe( + 'viewBox: 0 0 1000 500 (all coordinates in these user units, origin top-left)', + ); + expect(lines[1]).toContain('.desk{fill:#dde, stroke:#446}'); + expect(text).toContain( + '| table-01.001 | rect | desk | #dde | 100 200 30 20 |', + ); + expect(text).toContain('anon pathx1'); + expect(text).not.toContain('| grad |'); + expect(text).toContain('text="Meeting 1.01(8 seats)"'); + }); + + describe.each(SAMPLE_MAPS)('sample map %s', (name) => { + it('indexes every element with valid parents and boxes for id shapes', () => { + const doc = loadMap(name, { geometry }); + const started = Date.now(); + const index = buildIndex(doc); + const elapsed = Date.now() - started; + expect(index.entries.length).toBe(doc.elementCount()); + for (const entry of index.entries) { + if (entry.parent) + expect(index.by_anchor.has(entry.parent)).toBe(true); + if ( + entry.id && + entry.kind !== 'non-rendered' && + entry.tag !== 'g' + ) + expect(entry.bbox).not.toBeNull(); + } + expect(index.stats.elements).toBe(doc.elementCount()); + expect(elapsed).toBeLessThan(10_000); + const prompt = promptLines(index); + expect(prompt.split('\n').length).toBeGreaterThan(3); + expect(prompt.length).toBeLessThan(40_000); + }); + }); + + it('does not take plural layer groups for bookable elements', () => { + const doc = loadMap('level_1', { geometry }); + const index = buildIndex(doc, { bbox: false }); + const groups = index.entries.filter((entry) => + entry.id?.startsWith('tables-'), + ); + expect(groups.length).toBeGreaterThan(0); + for (const entry of groups) { + expect(entry.tag).toBe('g'); + expect(['layer-group', 'group']).toContain(entry.kind); + } + expect( + index.entries.find((entry) => entry.id === 'table-01.004')?.kind, + ).toBe('bookable'); + }); + + it('reports the duplicate id level_2 ships', () => { + const doc = loadMap('level_2', { geometry }); + const index = buildIndex(doc, { bbox: false }); + expect(index.duplicate_ids).toEqual(['table-02.013']); + expect(promptLines(index)).toContain( + 'duplicate ids already in the file (not your doing): table-02.013', + ); + }); +}); diff --git a/libs/map-editor/src/tests/ops-schema.spec.ts b/libs/map-editor/src/tests/ops-schema.spec.ts new file mode 100644 index 00000000000..6b34b41981d --- /dev/null +++ b/libs/map-editor/src/tests/ops-schema.spec.ts @@ -0,0 +1,82 @@ +import { OP_KINDS, targetsOf } from '../lib/ops'; +import { + opKindsInSchema, + OPS_SCHEMA, + schemaFieldsFor, +} from '../lib/ops-schema'; + +describe('ops-schema.json', () => { + it('lists exactly the op kinds of the TypeScript union, in order', () => { + expect(opKindsInSchema()).toEqual([...OP_KINDS]); + }); + + it('is in the OpenAI strict dialect', () => { + expect(OPS_SCHEMA.additionalProperties).toBe(false); + expect(OPS_SCHEMA.required).toEqual([ + 'schema_version', + 'summary', + 'ops', + 'questions', + ]); + expect(OPS_SCHEMA.properties.schema_version.enum).toEqual([1]); + for (const variant of OPS_SCHEMA.properties.ops.items.anyOf) { + expect(variant.additionalProperties).toBe(false); + expect(variant.required).toEqual(Object.keys(variant.properties)); + expect(variant.required).toContain('why'); + } + for (const def of Object.values(OPS_SCHEMA.$defs)) { + expect(def.required).toEqual(Object.keys(def.properties)); + } + expect(Object.keys(OPS_SCHEMA.$defs)).toEqual(['target', 'name_value']); + }); + + it('leaves the server bookkeeping out of every variant', () => { + for (const variant of OPS_SCHEMA.properties.ops.items.anyOf) { + expect(variant.properties).not.toHaveProperty('id'); + expect(variant.properties).not.toHaveProperty('server_issue'); + } + }); + + it('carries the fields each op needs', () => { + expect(schemaFieldsFor('set_attr')).toEqual([ + 'op', + 'why', + 'target', + 'name', + 'value', + ]); + expect(schemaFieldsFor('transform')).toEqual([ + 'op', + 'why', + 'target', + 'targets', + 'translate', + 'scale', + 'rotate', + 'origin', + ]); + expect(schemaFieldsFor('insert_shape')).toEqual([ + 'op', + 'why', + 'parent', + 'after', + 'kind', + 'attrs', + 'new_id', + 'text', + ]); + expect(schemaFieldsFor('bogus')).toEqual([]); + }); + + it('collects targets from single and multi-target ops', () => { + expect( + targetsOf({ + op: 'delete', + why: '', + target: { a: 'e1' }, + targets: [{ a: 'e2' }], + }), + ).toEqual([{ a: 'e1' }, { a: 'e2' }]); + expect(targetsOf({ op: 'ask', why: '', question: '?' })).toEqual([]); + }); +}); diff --git a/libs/map-editor/src/tests/protected-ids.spec.ts b/libs/map-editor/src/tests/protected-ids.spec.ts new file mode 100644 index 00000000000..0261fc96baa --- /dev/null +++ b/libs/map-editor/src/tests/protected-ids.spec.ts @@ -0,0 +1,44 @@ +import { + collectProtectedIds, + mergeProtectedIds, + ProtectedIdSet, +} from '../lib/protected-ids'; + +describe('protected ids', () => { + it('merges sources and marks the set incomplete when one fails', async () => { + const set = await collectProtectedIds({ + desks: async () => [ + { id: 'table-01.001', kind: 'desk', label: 'Desk 1' }, + ], + rooms: async () => { + throw new Error('offline'); + }, + areas: async () => [ + { id: 'area-1.01-status', kind: 'area', label: 'Meeting 1.01' }, + { id: 'table-01.001', kind: 'desk', label: '' }, + ], + }); + expect(set.complete).toBe(false); + expect(set.failed_sources).toEqual(['rooms']); + expect(set.size).toBe(2); + expect(set.has('table-01.001')).toBe(true); + expect(set.has('nope')).toBe(false); + expect(set.has(null)).toBe(false); + expect(set.labelOf('table-01.001')).toBe('Desk 1'); + expect(set.labelOf('unknown')).toBe('unknown'); + expect(set.ids().map((entry) => entry.id)).toEqual([ + 'table-01.001', + 'area-1.01-status', + ]); + }); + + it('is complete when every source answers', () => { + const set = mergeProtectedIds([ + { source: 'a', ids: [{ id: 'x', kind: 'other', label: 'X' }] }, + { source: 'b', ids: [] }, + ]); + expect(set.complete).toBe(true); + expect(set.failed_sources).toEqual([]); + expect(ProtectedIdSet.empty().size).toBe(0); + }); +}); diff --git a/libs/map-editor/src/tests/sanitise.spec.ts b/libs/map-editor/src/tests/sanitise.spec.ts new file mode 100644 index 00000000000..e38a50792bf --- /dev/null +++ b/libs/map-editor/src/tests/sanitise.spec.ts @@ -0,0 +1,237 @@ +import { MapDocument } from '../lib/map-document'; +import { + cleanStyleText, + decodeCssEscapes, + isUnsafeCssValue, + purifyMarkup, + sanitiseTree, +} from '../lib/sanitise'; +import { loadMap, readMap, SAMPLE_MAPS, svg } from './fixtures'; + +const DIRTY = ` + + Hi there + + + + + +
    html
    + + + + + + + + +