UNPKG

@obliczeniowo/elementary

Version:
461 lines (455 loc) 28.5 kB
import * as i0 from '@angular/core'; import { input, EventEmitter, effect, HostBinding, HostListener, Output, Component, Input, ViewChild, ViewChildren, NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Point2D, ColorRGB } from '@obliczeniowo/elementary/classes'; import { NG_VALUE_ACCESSOR, NG_VALIDATORS } from '@angular/forms'; import * as i1 from '@obliczeniowo/elementary/connectors'; import { RelationConnectorComponent, ConnectorsModule } from '@obliczeniowo/elementary/connectors'; import * as i2 from '@obliczeniowo/elementary/dialogs'; import { DialogWrapperComponent, DialogsModule } from '@obliczeniowo/elementary/dialogs'; import * as i3 from '@obliczeniowo/elementary/color-picker'; import { ColorComponent, ColorPickerModule } from '@obliczeniowo/elementary/color-picker'; import * as i4 from '@obliczeniowo/elementary/buttons'; import { ButtonsModule } from '@obliczeniowo/elementary/buttons'; import { MenuModule } from '@obliczeniowo/elementary/menu'; class RelationItemComponent { elementRef; category = input({ name: 'unknown' }); side = input('left'); relations = input([]); menu = input(); disabled = input(); clicked = new EventEmitter(); remove = new EventEmitter(); editColor = new EventEmitter(); subscription; haveRelations = false; onClick() { this.clicked.emit({ side: this.side(), category: this.category(), pos: this.position }); } hovered = new EventEmitter(); menuItems = [ { id: 1, text: 'Delete', command: 'delete', }, { id: 2, text: 'Set color', command: 'set-color', } ]; onMouseOver() { this.hovered.emit(this); const menu = this.menu(); if (menu && !this.disabled()) { menu.items = this.haveRelations ? this.menuItems : []; } } onMouseLeave() { const menu = this.menu(); if (menu && !menu?.opened) { menu.items = []; } } get isLeft() { return this.side() === 'left'; } get isRight() { return this.side() === 'right'; } constructor(elementRef) { this.elementRef = elementRef; effect(() => { this.update(); }); effect(() => { const menu = this.menu(); if (this.subscription && !this.subscription?.closed) { this.subscription.unsubscribe(); } if (!menu) { return; } this.subscription = menu.command.subscribe((command) => { if (menu?.items !== this.menuItems) { return; } switch (command) { case 'delete': this.remove.emit({ category: this.category(), side: this.side() }); if (menu) { menu.items = []; } this.haveRelations = false; break; case 'set-color': this.editColor.emit({ category: this.category(), side: this.side() }); break; case 'on-close': menu.items = []; break; } }); }); effect(() => { const disabled = this.disabled(); const menu = this.menu(); if (!disabled && menu) { menu.items = []; } }); } ngOnDestroy() { if (this.subscription && !this.subscription?.closed) { this.subscription.unsubscribe(); } } update() { if (this.category() && this.relations()) { setTimeout(() => { const items = this.relations().filter(relation => relation.connector?.[this.side()] === this.category().name); const dy = 1; this.haveRelations = !!items.length; let pos = this.position; pos = new Point2D(pos.x, pos.y - items.length * dy * 0.5); items.forEach((item, index) => { item[this.side()] = pos.add(new Point2D(0, dy * index)); item.change.detectChanges(); }); }); } } get position() { const rect = this.elementRef.nativeElement.getBoundingClientRect(); const parent = this.elementRef.nativeElement.parentElement?.parentElement?.getBoundingClientRect(); const side = this.side() === 'left' ? 'right' : 'left'; const dx = side === 'right' ? 10 : -10; return { x: rect[side] - parent.left + dx, y: rect.top + rect.height / 2 - parent.y }; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: RelationItemComponent, deps: [{ token: i0.ElementRef }], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "20.0.4", type: RelationItemComponent, isStandalone: false, selector: "obl-relation-item", inputs: { category: { classPropertyName: "category", publicName: "category", isSignal: true, isRequired: false, transformFunction: null }, side: { classPropertyName: "side", publicName: "side", isSignal: true, isRequired: false, transformFunction: null }, relations: { classPropertyName: "relations", publicName: "relations", isSignal: true, isRequired: false, transformFunction: null }, menu: { classPropertyName: "menu", publicName: "menu", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { clicked: "clicked", remove: "remove", editColor: "editColor", hovered: "hovered" }, host: { listeners: { "click": "onClick()", "mouseover": "onMouseOver()", "mouseleave": "onMouseLeave()" }, properties: { "class.left": "this.isLeft", "class.right": "this.isRight" } }, ngImport: i0, template: "<div class=\"connection-point\"></div>\n{{ category().name }}\n", styles: [":host{display:flex;width:100%;font-size:12px;padding:20px 0;position:relative}:host:hover{color:gray;cursor:pointer}:host .connection-point{position:absolute;width:10px;height:10px;background-color:var(--relation-connector-color, gray);border-radius:50%}:host.right .connection-point{left:-15px;top:calc(50% - 5px)}:host.left .connection-point{right:-15px;top:calc(50% - 5px)}\n"] }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: RelationItemComponent, decorators: [{ type: Component, args: [{ selector: 'obl-relation-item', standalone: false, template: "<div class=\"connection-point\"></div>\n{{ category().name }}\n", styles: [":host{display:flex;width:100%;font-size:12px;padding:20px 0;position:relative}:host:hover{color:gray;cursor:pointer}:host .connection-point{position:absolute;width:10px;height:10px;background-color:var(--relation-connector-color, gray);border-radius:50%}:host.right .connection-point{left:-15px;top:calc(50% - 5px)}:host.left .connection-point{right:-15px;top:calc(50% - 5px)}\n"] }] }], ctorParameters: () => [{ type: i0.ElementRef }], propDecorators: { clicked: [{ type: Output }], remove: [{ type: Output }], editColor: [{ type: Output }], onClick: [{ type: HostListener, args: ['click'] }], hovered: [{ type: Output }], onMouseOver: [{ type: HostListener, args: ['mouseover'] }], onMouseLeave: [{ type: HostListener, args: ['mouseleave'] }], isLeft: [{ type: HostBinding, args: ['class.left'] }], isRight: [{ type: HostBinding, args: ['class.right'] }] } }); /** * Relations item let you create corelation between left items and right one in graphical way and modify them * * [menu] - add context menu component {@link MenuComponent} from {@link MenuModule}. Do not set menu items, this component * will set them up by them self * [translations] - set the translations using as key current (default) english text and value as new one (translated) to display */ class CategoriesRelationsComponent { elementRef; changes; /** * right side categories */ left = []; /** * left side categories */ right = []; /** * connectors array to create {@link RelationConnectorComponent} */ connectors = []; /** * [translations] - set the translations using as key current (default) english text and value as new one (translated) to display */ translations = input({}); // context menu component reference menu = input(); changed = new EventEmitter(); color; width = 0; height = 0; observer = new ResizeObserver(() => { this.width = this.elementRef.nativeElement.clientWidth; this.height = this.elementRef.nativeElement.clientHeight; this.items?.forEach(item => item.update()); }); highlight; categoryItemSelected; mousePos; set relations(relation) { setTimeout(() => this.relationsChanged = relation.toArray()); } ; items; colorPicker; onMouseOver() { const menu = this.menu(); if (menu) { menu.target = this.elementRef.nativeElement; menu.translations = this.translations(); } } disabled; relationsChanged = []; target; connectorsSelected; constructor(elementRef, changes) { this.elementRef = elementRef; this.changes = changes; } onChange = (value) => { this.changed.emit(value); }; onTouched = () => { }; onMove(event) { const rect = this.elementRef.nativeElement.getBoundingClientRect(); const mousePos = { x: event.x - rect.x, y: event.y - rect.y }; if (this.categoryItemSelected) { if (this.categoryItemSelected.side === 'left') { this.mousePos = { x: Math.max(mousePos.x, this.categoryItemSelected?.pos.x + 50), y: mousePos.y }; } else { this.mousePos = { x: Math.min(mousePos.x, this.categoryItemSelected?.pos.x), y: mousePos.y }; } } } writeValue(obj) { Object.assign(this, obj); } registerOnChange(onChange) { this.onChange = onChange; } registerOnTouched(onTouched) { this.onTouched = onTouched; } setDisabledState(isDisabled) { this.disabled = isDisabled; } validate(control) { return null; } ngOnInit() { this.observer.observe(this.elementRef.nativeElement); } ngOnDestroy() { this.observer.unobserve(this.elementRef.nativeElement); } onHoverOverItem(item) { if (this.categoryItemSelected?.side !== item.side()) { this.mousePos = item.position; } else { const { x, y } = this.categoryItemSelected.pos; this.mousePos = { x: this.categoryItemSelected?.side === 'left' ? x + 50 : x - 50, y }; } } onRemove(item) { this.connectors = this.connectors.filter(connector => connector[item.side] !== item.category.name); const prepare = { left: this.left, right: this.right, connectors: this.connectors }; this.changed.emit(prepare); if (this.onChange) { this.onChange(prepare); } const menu = this.menu(); if (menu) { menu.items = []; } } setColor(color) { this.color.close(); if (this.connectorsSelected) { this.connectorsSelected.forEach(item => { item.color = color.getHexColorBase; }); this.connectors = [...this.connectors]; const prepare = { left: this.left, right: this.right, connectors: this.connectors }; this.changed.emit(prepare); if (this.onChange) { this.onChange(prepare); } } } onEditColor(item) { if (this.disabled) { return; } this.color.open({ minWidth: '540px' }); this.colorPicker.chosenColor = ColorRGB.fromHtmlColor(item.category?.color || '#ff0000'); this.connectorsSelected = this.connectors.filter(connector => connector[item.side] === item.category.name); const menu = this.menu(); if (menu) { menu.items = []; } } onEditColorConnector(connector) { if (this.disabled) { return; } this.connectorsSelected = [connector]; this.color.open({ minWidth: '540px' }); this.colorPicker.chosenColor = ColorRGB.fromHtmlColor(connector.color || '#ff0000'); this.colorPicker.ngAfterViewInit(); } removeConnector(connector) { if (this.disabled) { return; } this.connectors = this.connectors.filter(conn => !(conn.left === connector.left && conn.right === connector.right)); this.onChange({ left: this.left, right: this.right, connectors: this.connectors }); this.changes.detectChanges(); } addConnector(categoryItem) { if (this.disabled) { return; } if (!this.categoryItemSelected) { this.categoryItemSelected = categoryItem; this.mousePos = { x: this.categoryItemSelected.pos.x + (this.categoryItemSelected.side === 'left' ? 50 : -50), y: this.categoryItemSelected.pos.y }; } else { if (this.categoryItemSelected.side === categoryItem.side) { this.categoryItemSelected = undefined; return; } if (this.connectors.find(con => con?.[categoryItem.side] === categoryItem.category.name && this.categoryItemSelected && con?.[this.categoryItemSelected.side] === this.categoryItemSelected.category.name)) { this.categoryItemSelected = undefined; return; } this.connectors = [...this.connectors, { [categoryItem.side]: categoryItem.category.name, [this.categoryItemSelected.side]: this.categoryItemSelected.category.name, color: categoryItem.side === 'left' ? categoryItem.category.color : this.categoryItemSelected.category.color }].sort((a, b) => { return this.left.findIndex(w => w.name === a.left) * this.left.length - this.left.findIndex(w => w.name === b.left) * this.left.length + this.right.findIndex(w => w.name === a.right) - this.right.findIndex(w => w.name === b.right); }); this.onChange({ left: this.left, right: this.right, connectors: this.connectors }); this.categoryItemSelected = undefined; } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: CategoriesRelationsComponent, deps: [{ token: i0.ElementRef }, { token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.0.4", type: CategoriesRelationsComponent, isStandalone: false, selector: "obl-categories-relations", inputs: { left: { classPropertyName: "left", publicName: "left", isSignal: false, isRequired: false, transformFunction: null }, right: { classPropertyName: "right", publicName: "right", isSignal: false, isRequired: false, transformFunction: null }, connectors: { classPropertyName: "connectors", publicName: "connectors", isSignal: false, isRequired: false, transformFunction: null }, translations: { classPropertyName: "translations", publicName: "translations", isSignal: true, isRequired: false, transformFunction: null }, menu: { classPropertyName: "menu", publicName: "menu", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: false, isRequired: false, transformFunction: null } }, outputs: { changed: "changed" }, host: { listeners: { "mouseover": "onMouseOver()" } }, providers: [ { provide: NG_VALUE_ACCESSOR, multi: true, useExisting: CategoriesRelationsComponent }, { provide: NG_VALIDATORS, multi: true, useExisting: CategoriesRelationsComponent } ], viewQueries: [{ propertyName: "color", first: true, predicate: DialogWrapperComponent, descendants: true }, { propertyName: "colorPicker", first: true, predicate: ColorComponent, descendants: true }, { propertyName: "relations", predicate: RelationConnectorComponent, descendants: true }, { propertyName: "items", predicate: RelationItemComponent, descendants: true }], ngImport: i0, template: "<svg\n [attr.width]=\"width\"\n [attr.height]=\"height\"\n (mousemove)=\"onMove($event)\"\n (click)=\"categoryItemSelected = undefined\"\n>\n @for(connector of connectors; track $index) {\n <g\n obl-relation-connector\n [connector]=\"connector\"\n [highlighted]=\"highlight\"\n [disabled]=\"disabled\"\n [menu]=\"menu()\"\n [translations]=\"translations()\"\n (clicked)=\"removeConnector($event)\"\n (remove)=\"removeConnector($event)\"\n (editColor)=\"onEditColorConnector($event)\"\n ></g>\n }\n\n @if (categoryItemSelected) {\n <g\n obl-relation-connector\n class=\"selected\"\n [left]=\"\n categoryItemSelected.side === 'left' ? categoryItemSelected.pos : mousePos\n \"\n [right]=\"\n categoryItemSelected.side === 'right'\n ? categoryItemSelected.pos\n : mousePos\n \"\n [defaultColor]=\"'red'\"\n (clicked)=\"removeConnector($event)\"\n ></g>\n }\n</svg>\n\n<div class=\"left-section\">\n @for(item of left; track item.name) {\n <obl-relation-item\n [category]=\"item\"\n [relations]=\"relationsChanged\"\n [disabled]=\"disabled\"\n [menu]=\"menu()\"\n (mouseover)=\"highlight = item.name\"\n (mouseleave)=\"highlight = undefined\"\n (clicked)=\"addConnector($event)\"\n (hovered)=\"onHoverOverItem($event)\"\n (remove)=\"onRemove($event)\"\n (editColor)=\"onEditColor($event)\"\n ></obl-relation-item>\n }\n</div>\n\n<div class=\"right-section\">\n @for (item of right; track item.name) {\n <obl-relation-item\n [category]=\"item\"\n [relations]=\"relationsChanged\"\n [side]=\"'right'\"\n [disabled]=\"disabled\"\n [menu]=\"menu()\"\n (mouseover)=\"highlight = item.name\"\n (mouseleave)=\"highlight = undefined\"\n (clicked)=\"addConnector($event)\"\n (hovered)=\"onHoverOverItem($event)\"\n (remove)=\"onRemove($event)\"\n (editColor)=\"onEditColor($event)\"\n ></obl-relation-item>\n }\n</div>\n\n<obl-dialog-wrapper\n [header]=\"translations()['Color picker'] || 'Color picker'\"\n #color\n>\n <obl-color\n [translations]=\"{\n red: translations()['Red'] || 'Red',\n green: translations()['Green'] || 'Green',\n blue: translations()['Blue'] || 'Blue',\n alpha: translations()['Alpha'] || 'Transparent'\n }\"\n #colorSet\n ></obl-color>\n <div class=\"buttons\">\n <button oblButton (click)=\"setColor(colorSet.chosenColor)\">\n {{ translations()['Ok'] || 'Ok' }}\n </button>\n <button oblButton (click)=\"color.close()\">\n {{ translations()['Cancel'] || 'Cancel' }}\n </button>\n </div>\n</obl-dialog-wrapper>\n", styles: [":host{display:flex;position:relative;justify-content:space-between;align-items:center}svg{position:absolute;left:0;top:0;z-index:0}div{z-index:2}.left-section,.right-section{max-width:30%;display:flex;flex-direction:column;align-items:stretch}.selected{--connector-stroke-width-bold: 6;--connector-stroke-width: 6}obl-menu{position:absolute}::ng-deep .obl-menu{z-index:10000}obl-dialog-wrapper{display:none}.buttons{display:flex;justify-content:flex-end;padding:10px}\n"], dependencies: [{ kind: "component", type: i1.RelationConnectorComponent, selector: "g[obl-relation-connector]", inputs: ["left", "right", "connector", "highlighted", "offset", "defaultColor", "menu", "translations", "disabled", "input"], outputs: ["clicked", "remove", "editColor"] }, { kind: "component", type: i2.DialogWrapperComponent, selector: "obl-dialog-wrapper", inputs: ["header"], outputs: ["closed", "cancel"] }, { kind: "component", type: i3.ColorComponent, selector: "obl-color", inputs: ["chosenColor", "inputBorder", "translations"], outputs: ["changed"] }, { kind: "directive", type: i4.ButtonDirective, selector: "oblButton, [oblButton]", inputs: ["oblButton", "icon", "blockWhenLoading", "loading", "toggle", "selected", "link", "target"], exportAs: ["oblButton"] }, { kind: "component", type: RelationItemComponent, selector: "obl-relation-item", inputs: ["category", "side", "relations", "menu", "disabled"], outputs: ["clicked", "remove", "editColor", "hovered"] }] }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: CategoriesRelationsComponent, decorators: [{ type: Component, args: [{ selector: 'obl-categories-relations', providers: [ { provide: NG_VALUE_ACCESSOR, multi: true, useExisting: CategoriesRelationsComponent }, { provide: NG_VALIDATORS, multi: true, useExisting: CategoriesRelationsComponent } ], standalone: false, template: "<svg\n [attr.width]=\"width\"\n [attr.height]=\"height\"\n (mousemove)=\"onMove($event)\"\n (click)=\"categoryItemSelected = undefined\"\n>\n @for(connector of connectors; track $index) {\n <g\n obl-relation-connector\n [connector]=\"connector\"\n [highlighted]=\"highlight\"\n [disabled]=\"disabled\"\n [menu]=\"menu()\"\n [translations]=\"translations()\"\n (clicked)=\"removeConnector($event)\"\n (remove)=\"removeConnector($event)\"\n (editColor)=\"onEditColorConnector($event)\"\n ></g>\n }\n\n @if (categoryItemSelected) {\n <g\n obl-relation-connector\n class=\"selected\"\n [left]=\"\n categoryItemSelected.side === 'left' ? categoryItemSelected.pos : mousePos\n \"\n [right]=\"\n categoryItemSelected.side === 'right'\n ? categoryItemSelected.pos\n : mousePos\n \"\n [defaultColor]=\"'red'\"\n (clicked)=\"removeConnector($event)\"\n ></g>\n }\n</svg>\n\n<div class=\"left-section\">\n @for(item of left; track item.name) {\n <obl-relation-item\n [category]=\"item\"\n [relations]=\"relationsChanged\"\n [disabled]=\"disabled\"\n [menu]=\"menu()\"\n (mouseover)=\"highlight = item.name\"\n (mouseleave)=\"highlight = undefined\"\n (clicked)=\"addConnector($event)\"\n (hovered)=\"onHoverOverItem($event)\"\n (remove)=\"onRemove($event)\"\n (editColor)=\"onEditColor($event)\"\n ></obl-relation-item>\n }\n</div>\n\n<div class=\"right-section\">\n @for (item of right; track item.name) {\n <obl-relation-item\n [category]=\"item\"\n [relations]=\"relationsChanged\"\n [side]=\"'right'\"\n [disabled]=\"disabled\"\n [menu]=\"menu()\"\n (mouseover)=\"highlight = item.name\"\n (mouseleave)=\"highlight = undefined\"\n (clicked)=\"addConnector($event)\"\n (hovered)=\"onHoverOverItem($event)\"\n (remove)=\"onRemove($event)\"\n (editColor)=\"onEditColor($event)\"\n ></obl-relation-item>\n }\n</div>\n\n<obl-dialog-wrapper\n [header]=\"translations()['Color picker'] || 'Color picker'\"\n #color\n>\n <obl-color\n [translations]=\"{\n red: translations()['Red'] || 'Red',\n green: translations()['Green'] || 'Green',\n blue: translations()['Blue'] || 'Blue',\n alpha: translations()['Alpha'] || 'Transparent'\n }\"\n #colorSet\n ></obl-color>\n <div class=\"buttons\">\n <button oblButton (click)=\"setColor(colorSet.chosenColor)\">\n {{ translations()['Ok'] || 'Ok' }}\n </button>\n <button oblButton (click)=\"color.close()\">\n {{ translations()['Cancel'] || 'Cancel' }}\n </button>\n </div>\n</obl-dialog-wrapper>\n", styles: [":host{display:flex;position:relative;justify-content:space-between;align-items:center}svg{position:absolute;left:0;top:0;z-index:0}div{z-index:2}.left-section,.right-section{max-width:30%;display:flex;flex-direction:column;align-items:stretch}.selected{--connector-stroke-width-bold: 6;--connector-stroke-width: 6}obl-menu{position:absolute}::ng-deep .obl-menu{z-index:10000}obl-dialog-wrapper{display:none}.buttons{display:flex;justify-content:flex-end;padding:10px}\n"] }] }], ctorParameters: () => [{ type: i0.ElementRef }, { type: i0.ChangeDetectorRef }], propDecorators: { left: [{ type: Input }], right: [{ type: Input }], connectors: [{ type: Input }], changed: [{ type: Output }], color: [{ type: ViewChild, args: [DialogWrapperComponent] }], relations: [{ type: ViewChildren, args: [RelationConnectorComponent] }], items: [{ type: ViewChildren, args: [RelationItemComponent] }], colorPicker: [{ type: ViewChild, args: [ColorComponent] }], onMouseOver: [{ type: HostListener, args: ['mouseover'] }], disabled: [{ type: Input }] } }); class CategoriesRelationsModule { static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: CategoriesRelationsModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "20.0.4", ngImport: i0, type: CategoriesRelationsModule, declarations: [RelationItemComponent, CategoriesRelationsComponent], imports: [CommonModule, ConnectorsModule, MenuModule, DialogsModule, ColorPickerModule, ButtonsModule], exports: [CategoriesRelationsComponent] }); static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: CategoriesRelationsModule, imports: [CommonModule, ConnectorsModule, MenuModule, DialogsModule, ColorPickerModule, ButtonsModule] }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: CategoriesRelationsModule, decorators: [{ type: NgModule, args: [{ declarations: [ RelationItemComponent, CategoriesRelationsComponent, ], imports: [ CommonModule, ConnectorsModule, MenuModule, DialogsModule, ColorPickerModule, ButtonsModule ], exports: [ CategoriesRelationsComponent ] }] }] }); /** * Generated bundle index. Do not edit. */ export { CategoriesRelationsComponent, CategoriesRelationsModule }; //# sourceMappingURL=obliczeniowo-elementary-categories-relations.mjs.map