From c97cff4d79684405616c0cb05acf73bddb014e80 Mon Sep 17 00:00:00 2001 From: Jonah <47046556+jwbonner@users.noreply.github.com> Date: Thu, 8 Aug 2024 21:55:17 -0400 Subject: [PATCH 001/164] Add source list element --- package-lock.json | 32 + package.json | 2 + src/hub/SourceList.ts | 425 ++++++ src/hub/Tabs.ts | 213 ++- src/hub/hub.ts | 8 + src/main/main.ts | 118 +- src/shared/SourceListConfig.ts | 295 ++++ www/global.css | 11 + www/hub.css | 1299 +---------------- www/hub.html | 694 +-------- www/symbols/sourceList/README.md | 9 + .../arrow.counterclockwise.circle.fill.svg | 11 + ....up.and.line.horizontal.and.arrow.down.svg | 12 + www/symbols/sourceList/arrow.up.circle.svg | 12 + ...right.and.arrow.up.right.and.down.left.svg | 11 + www/symbols/sourceList/camera.fill.svg | 11 + www/symbols/sourceList/circle.circle.fill.svg | 11 + .../sourceList/circle.dotted.circle.fill.svg | 11 + www/symbols/sourceList/circle.fill.svg | 11 + www/symbols/sourceList/cone.fill.svg | 11 + www/symbols/sourceList/gearshape.2.fill.svg | 11 + www/symbols/sourceList/line.3.horizontal.svg | 13 + www/symbols/sourceList/location.fill.svg | 11 + .../sourceList/location.fill.viewfinder.svg | 12 + www/symbols/sourceList/map.fill.svg | 11 + www/symbols/sourceList/mappin.circle.fill.svg | 11 + www/symbols/sourceList/move.3d.svg | 11 + ...ward.to.point.topright.scurvepath.fill.svg | 12 + .../sourceList/puzzlepiece.extension.fill.svg | 11 + www/symbols/sourceList/qrcode.svg | 11 + www/symbols/sourceList/scope.svg | 11 + www/symbols/sourceList/star.fill.svg | 11 + 32 files changed, 1333 insertions(+), 2010 deletions(-) create mode 100644 src/hub/SourceList.ts create mode 100644 src/shared/SourceListConfig.ts create mode 100644 www/symbols/sourceList/README.md create mode 100644 www/symbols/sourceList/arrow.counterclockwise.circle.fill.svg create mode 100644 www/symbols/sourceList/arrow.up.and.line.horizontal.and.arrow.down.svg create mode 100644 www/symbols/sourceList/arrow.up.circle.svg create mode 100644 www/symbols/sourceList/arrow.up.left.and.down.right.and.arrow.up.right.and.down.left.svg create mode 100644 www/symbols/sourceList/camera.fill.svg create mode 100644 www/symbols/sourceList/circle.circle.fill.svg create mode 100644 www/symbols/sourceList/circle.dotted.circle.fill.svg create mode 100644 www/symbols/sourceList/circle.fill.svg create mode 100644 www/symbols/sourceList/cone.fill.svg create mode 100644 www/symbols/sourceList/gearshape.2.fill.svg create mode 100644 www/symbols/sourceList/line.3.horizontal.svg create mode 100644 www/symbols/sourceList/location.fill.svg create mode 100644 www/symbols/sourceList/location.fill.viewfinder.svg create mode 100644 www/symbols/sourceList/map.fill.svg create mode 100644 www/symbols/sourceList/mappin.circle.fill.svg create mode 100644 www/symbols/sourceList/move.3d.svg create mode 100644 www/symbols/sourceList/point.bottomleft.forward.to.point.topright.scurvepath.fill.svg create mode 100644 www/symbols/sourceList/puzzlepiece.extension.fill.svg create mode 100644 www/symbols/sourceList/qrcode.svg create mode 100644 www/symbols/sourceList/scope.svg create mode 100644 www/symbols/sourceList/star.fill.svg diff --git a/package-lock.json b/package-lock.json index e469d88b..61399c6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@rollup/plugin-replace": "^5.0.2", "@rollup/plugin-typescript": "11.1.3", "@types/chart.js": "^2.9.38", + "@types/color-convert": "^2.0.3", "@types/download": "^8.0.2", "@types/heatmap.js": "2.0.38", "@types/jsonfile": "^6.1.2", @@ -35,6 +36,7 @@ "@types/ssh2": "^1.11.13", "@types/three": "^0.156.0", "chart.js": "^4.4.0", + "color-convert": "^2.0.1", "electron": "^26.2.1", "electron-builder": "^24.6.4", "fuse.js": "^7.0.0", @@ -785,6 +787,21 @@ "moment": "^2.10.2" } }, + "node_modules/@types/color-convert": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/color-convert/-/color-convert-2.0.3.tgz", + "integrity": "sha512-2Q6wzrNiuEvYxVQqhh7sXM2mhIhvZR/Paq4FdsQkOMgWsCIkKvSGj8Le1/XalulrmgOzPMqNa0ix+ePY4hTrfg==", + "dev": true, + "dependencies": { + "@types/color-name": "*" + } + }, + "node_modules/@types/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-hulKeREDdLFesGQjl96+4aoJSHY5b2GRjagzzcqCfIrWhe5vkCqIvrLbqzBaI1q94Vg8DNJZZqTR5ocdWmWclg==", + "dev": true + }, "node_modules/@types/debug": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.8.tgz", @@ -5615,6 +5632,21 @@ "moment": "^2.10.2" } }, + "@types/color-convert": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/color-convert/-/color-convert-2.0.3.tgz", + "integrity": "sha512-2Q6wzrNiuEvYxVQqhh7sXM2mhIhvZR/Paq4FdsQkOMgWsCIkKvSGj8Le1/XalulrmgOzPMqNa0ix+ePY4hTrfg==", + "dev": true, + "requires": { + "@types/color-name": "*" + } + }, + "@types/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-hulKeREDdLFesGQjl96+4aoJSHY5b2GRjagzzcqCfIrWhe5vkCqIvrLbqzBaI1q94Vg8DNJZZqTR5ocdWmWclg==", + "dev": true + }, "@types/debug": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.8.tgz", diff --git a/package.json b/package.json index 5c32be41..ed878538 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@rollup/plugin-replace": "^5.0.2", "@rollup/plugin-typescript": "11.1.3", "@types/chart.js": "^2.9.38", + "@types/color-convert": "^2.0.3", "@types/download": "^8.0.2", "@types/heatmap.js": "2.0.38", "@types/jsonfile": "^6.1.2", @@ -38,6 +39,7 @@ "@types/ssh2": "^1.11.13", "@types/three": "^0.156.0", "chart.js": "^4.4.0", + "color-convert": "^2.0.1", "electron": "^26.2.1", "electron-builder": "^24.6.4", "fuse.js": "^7.0.0", diff --git a/src/hub/SourceList.ts b/src/hub/SourceList.ts new file mode 100644 index 00000000..34928db5 --- /dev/null +++ b/src/hub/SourceList.ts @@ -0,0 +1,425 @@ +import { hex, hsl } from "color-convert"; +import { SourceListConfig, SourceListItemState, SourceListState } from "../shared/SourceListConfig"; +import LoggableType from "../shared/log/LoggableType"; +import { createUUID } from "../shared/util"; + +export default class SourceList { + static promptCallbacks: { [key: string]: (state: SourceListItemState) => void } = {}; + + private ITEM_TEMPLATE = document.getElementById("sourceListItemTemplate")?.firstElementChild as HTMLElement; + private ROOT: HTMLElement; + private TITLE: HTMLElement; + private LIST: HTMLElement; + private DRAG_HIGHLIGHT: HTMLElement; + + private stopped = false; + private config: SourceListConfig; + private state: SourceListState = []; + private allAllowedTypes: Set = new Set(); + private parentTypes: Set = new Set(); + + constructor(root: HTMLElement, config: SourceListConfig) { + this.config = config; + this.ROOT = root; + this.ROOT.classList.add("source-list"); + + this.TITLE = document.createElement("div"); + this.ROOT.appendChild(this.TITLE); + this.TITLE.classList.add("title"); + this.TITLE.innerText = config.title; + + this.LIST = document.createElement("div"); + this.ROOT.appendChild(this.LIST); + this.LIST.classList.add("list"); + + this.DRAG_HIGHLIGHT = document.createElement("div"); + this.ROOT.appendChild(this.DRAG_HIGHLIGHT); + this.DRAG_HIGHLIGHT.classList.add("drag-highlight"); + this.DRAG_HIGHLIGHT.hidden = true; + + // Summarize config + this.config.types.forEach((typeConfig) => { + typeConfig.sourceTypes.forEach((source) => { + this.allAllowedTypes.add(source); + }); + if (typeConfig.parentType !== undefined) { + this.parentTypes.add(typeConfig.parentType); + } + }); + + // Drag handling + window.addEventListener("drag-update", (event) => { + this.handleDrag((event as CustomEvent).detail); + }); + + // Periodic method + let lastIsDark: boolean | null = null; + let periodic = () => { + if (this.stopped) return; + + // Update items when theme changes (some icon colors will change) + let isDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + if (isDark !== lastIsDark) { + lastIsDark = isDark; + this.updateAllItems(); + } + + // Update value previews + this.updateAllPreviews(); + + window.requestAnimationFrame(periodic); + }; + window.requestAnimationFrame(periodic); + } + + saveState(): SourceListState { + return this.state; + } + + restoreState(state: SourceListState) { + this.state = state; + while (this.LIST.firstChild) { + this.LIST.removeChild(this.LIST.firstChild); + } + this.state.forEach((itemState) => { + this.addItem(itemState); + }); + } + + stop() { + this.stopped = true; + } + + /** Processes a drag event, including adding a field if necessary. */ + private handleDrag(dragData: any) { + let end = dragData.end; + let x = dragData.x; + let y = dragData.y; + let draggedFields: { fields: string[]; children: string[] } = dragData.data; + + // Exit if out of range + let listRect = this.ROOT.getBoundingClientRect(); + if (listRect.width === 0 || listRect.height === 0) { + this.DRAG_HIGHLIGHT.hidden = true; + return; + } + + // Check pixel ranges + let withinList = x > listRect.left && x < listRect.right && y > listRect.top && y < listRect.bottom; + let parentIndex: number | null = null; + for (let i = 0; i < this.LIST.childElementCount; i++) { + let itemRect = this.LIST.children[i].getBoundingClientRect(); + let withinItem = x > itemRect.left && x < itemRect.right && y > itemRect.top && y < itemRect.bottom; + if (withinItem && this.parentTypes.has(this.state[i].type)) { + parentIndex = i; + } + } + + // Check type validity + let isTypeValid = (sourceTypes: Set): boolean => { + return draggedFields.fields.some((field) => { + let logType = window.log.getType(field); + let logTypeString = logType === null ? null : LoggableType[logType]; + let structuredType = window.log.getStructuredType(field); + return ( + (logTypeString !== null && sourceTypes.has(logTypeString)) || + (structuredType !== null && sourceTypes.has(structuredType)) + ); + }); + }; + let typeValidList = isTypeValid(this.allAllowedTypes); + let typeValidParent = false; + if (parentIndex !== null) { + let childAllowedTypes: Set = new Set(); + this.config.types.forEach((typeConfig) => { + if (typeConfig.parentType === this.state[parentIndex!].type) { + typeConfig.sourceTypes.forEach((type) => childAllowedTypes.add(type)); + } + }); + typeValidParent = isTypeValid(childAllowedTypes); + } + + // Update highlight + if (end) { + this.DRAG_HIGHLIGHT.hidden = true; + let addChild = typeValidParent && parentIndex !== null; + let addList = typeValidList && withinList; + if (addChild || addList) { + draggedFields.fields.forEach((field) => { + let logType = window.log.getType(field); + let logTypeString = logType === null ? null : LoggableType[logType]; + let structuredType = window.log.getStructuredType(field); + for (let i = 0; i < this.config.types.length; i++) { + let typeConfig = this.config.types[i]; + if (addChild && typeConfig.parentType !== this.state[parentIndex!].type) { + // Not a child of this parent + continue; + } + let finalType = ""; + if (structuredType !== null && typeConfig.sourceTypes.includes(structuredType)) { + finalType = structuredType; + } else if (logTypeString !== null && typeConfig.sourceTypes.includes(logTypeString)) { + finalType = logTypeString; + } + if (finalType.length > 0) { + let options: { [key: string]: string } = {}; + typeConfig.options.forEach((optionConfig) => { + options[optionConfig.key] = optionConfig.values[0].key; + }); + let state: SourceListItemState = { + type: typeConfig.key, + logKey: field, + logType: finalType, + visible: true, + options: options + }; + if (addChild) { + let insertIndex = parentIndex! + 1; + while (insertIndex < this.state.length && this.isChild(insertIndex)) { + insertIndex++; + } + this.addItem(state, insertIndex); + } else { + this.addItem(state); + } + break; + } + } + }); + } + } else if (typeValidParent && parentIndex !== null) { + this.DRAG_HIGHLIGHT.style.left = "0%"; + this.DRAG_HIGHLIGHT.style.top = + (this.LIST.children[parentIndex!].getBoundingClientRect().top - listRect.top).toString() + "px"; + this.DRAG_HIGHLIGHT.style.width = "100%"; + this.DRAG_HIGHLIGHT.style.height = this.LIST.children[parentIndex!].clientHeight.toString() + "px"; + this.DRAG_HIGHLIGHT.hidden = false; + } else if (typeValidList && withinList) { + this.DRAG_HIGHLIGHT.style.left = "0%"; + this.DRAG_HIGHLIGHT.style.top = "0%"; + this.DRAG_HIGHLIGHT.style.width = "100%"; + this.DRAG_HIGHLIGHT.style.height = "100%"; + this.DRAG_HIGHLIGHT.hidden = false; + } else { + this.DRAG_HIGHLIGHT.hidden = true; + } + } + + /** Update all items to match the current state. */ + private updateAllItems() { + let count = Math.min(this.state.length, this.LIST.childElementCount); + for (let i = 0; i < count; i++) { + this.updateItem(this.LIST.children[i] as HTMLElement, this.state[i]); + } + } + + /** Update the preview values of all items. */ + private updateAllPreviews() { + let count = Math.min(this.state.length, this.LIST.childElementCount); + for (let i = 0; i < count; i++) { + this.updatePreview(this.LIST.children[i] as HTMLElement, this.state[i]); + } + } + + /** Make a list item and inserts it into the list. */ + private addItem(state: SourceListItemState, insertIndex?: number) { + let item = this.ITEM_TEMPLATE.cloneNode(true) as HTMLElement; + if (insertIndex === undefined) { + this.LIST.appendChild(item); + this.state.push(state); + } else { + this.LIST.insertBefore(item, this.LIST.children[insertIndex!]); + this.state.splice(insertIndex, 0, state); + } + this.updateItem(item, state); + + // Check if child type + let typeConfig = this.config.types.find((typeConfig) => typeConfig.key === state.type); + let isChild = typeConfig !== undefined && typeConfig.parentType !== undefined; + + // Type controls + let typeButton = item.getElementsByClassName("type")[0] as HTMLButtonElement; + let promptType = (coordinates: [number, number]) => { + const uuid = createUUID(); + let index = Array.from(this.LIST.children).indexOf(item); + window.sendMainMessage("source-list-type-prompt", { + uuid: uuid, + config: this.config, + state: this.state[index], + coordinates: coordinates + }); + let originalType = this.state[index].type; + SourceList.promptCallbacks[uuid] = (newState) => { + delete SourceList.promptCallbacks[uuid]; + let index = Array.from(this.LIST.children).indexOf(item); + this.state[index] = newState; + this.updateItem(item, newState); + + if (newState.type !== originalType && !isChild) { + // Changed parent type, remove all children + index++; + if (!this.isChild(index)) return; + let removeCount = 0; + while (index + removeCount < this.state.length) { + removeCount++; + if (!this.isChild(index + removeCount)) break; + } + this.state.splice(index, removeCount); + for (let i = 0; i < removeCount; i++) { + this.LIST.removeChild(this.LIST.children[index]); + } + } + }; + }; + typeButton.addEventListener("click", () => { + let rect = typeButton.getBoundingClientRect(); + promptType([Math.round(rect.right), Math.round(rect.top)]); + }); + item.addEventListener("contextmenu", (event) => { + promptType([event.clientX, event.clientY]); + }); + + // Hide button + let hideButton = item.getElementsByClassName("hide")[0] as HTMLButtonElement; + let toggleHidden = () => { + if (isChild) return; + let index = Array.from(this.LIST.children).indexOf(item); + let newVisible = !this.state[index].visible; + this.state[index].visible = newVisible; + this.updateItem(item, this.state[index]); + while (index < this.state.length) { + index++; + if (!this.isChild(index)) break; + this.state[index].visible = newVisible; + this.updateItem(this.LIST.children[index] as HTMLElement, this.state[index]); + } + }; + hideButton.addEventListener("click", (event) => { + event.preventDefault(); + toggleHidden(); + }); + let lastClick = 0; + item.addEventListener("click", () => { + let now = new Date().getTime(); + if (now - lastClick < 400) { + toggleHidden(); + lastClick = 0; + } else { + lastClick = now; + } + }); + + // Child formatting + if (isChild) { + hideButton.hidden = true; + item.classList.add("child"); + } + + // Remove button + let removeButton = item.getElementsByClassName("remove")[0] as HTMLButtonElement; + removeButton.addEventListener("click", () => { + let index = Array.from(this.LIST.children).indexOf(item); + let removeCount = 0; + while (index + removeCount < this.state.length) { + removeCount++; + if (isChild || !this.isChild(index + removeCount)) break; + } + this.state.splice(index, removeCount); + for (let i = 0; i < removeCount; i++) { + this.LIST.removeChild(this.LIST.children[index]); + } + }); + return item; + } + + /** + * Updates a list item to match the item state. + * + * @param item The HTML element to update + * @param state The desired display state + */ + private updateItem(item: HTMLElement, state: SourceListItemState) { + let typeConfig = this.config.types.find((typeConfig) => typeConfig.key === state.type); + if (typeConfig === undefined) throw 'Unknown type "' + state.type + '"'; + + // Update type icon + let typeIcon = item.getElementsByTagName("object")[0] as HTMLObjectElement; + let color: string; + let isDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + if (typeConfig.color.startsWith("#")) { + if (isDark && typeConfig.darkColor !== undefined) { + color = typeConfig.darkColor; + } else { + color = typeConfig.color; + } + } else { + color = state.options[typeConfig.color]; + } + let hslVal = hex.hsl(color.slice(1)); + hslVal[2] = isDark ? Math.max(hslVal[2], 65) : Math.min(hslVal[2], 45); // Ensure enough contrast with background + color = "#" + hsl.hex(hslVal); + let dataPath = "symbols/sourceList/" + typeConfig.symbol + ".svg"; + if (dataPath !== typeIcon.getAttribute("data")) { + typeIcon.data = dataPath; + typeIcon.addEventListener("load", () => { + if (typeIcon.contentDocument) { + typeIcon.contentDocument.getElementsByTagName("svg")[0].style.color = color; + } + }); + } else if (typeIcon.contentDocument !== null) { + typeIcon.contentDocument.getElementsByTagName("svg")[0].style.color = color; + } + + // Update type name + let typeNameComponents: string[] = []; + if (typeConfig.showInTypeName) { + typeNameComponents.push(typeConfig.display); + } + typeConfig.options.forEach((optionConfig) => { + if (optionConfig.showInTypeName) { + let valueKey = state.options[optionConfig.key]; + let valueConfig = optionConfig.values.find((value) => value.key === valueKey); + if (valueConfig === undefined) return; + typeNameComponents.push(valueConfig.display); + } + }); + let typeNameElement = item.getElementsByClassName("type-name")[0] as HTMLElement; + typeNameElement.innerText = typeNameComponents.join("/") + ":"; + + // Update log key + let keyContainer = item.getElementsByClassName("key-container")[0] as HTMLElement; + let keySpan = keyContainer.firstElementChild as HTMLElement; + keySpan.innerText = state.logKey; + keyContainer.style.setProperty("--type-width", typeNameElement.clientWidth.toString() + "px"); + + // Update hide button + let hideButton = item.getElementsByClassName("hide")[0] as HTMLButtonElement; + let hideIcon = hideButton.firstElementChild as HTMLImageElement; + hideIcon.src = "symbols/" + (state.visible ? "eye.slash.svg" : "eye.svg"); + if (state.visible) { + item.classList.remove("hidden"); + } else { + item.classList.add("hidden"); + } + } + + private isChild(index: number) { + if (index < 0 || index >= this.state.length) return false; + let typeConfig = this.config.types.find((typeConfig) => typeConfig.key === this.state[index].type); + return typeConfig !== undefined && typeConfig.parentType !== undefined; + } + + /** + * Updates the preview value of an item. + * + * @param item The HTML element to update + * @param state The associated item state + */ + private updatePreview(item: HTMLElement, state: SourceListItemState) { + let valueSymbol = item.getElementsByClassName("value-symbol")[0] as HTMLElement; + let valueText = item.getElementsByClassName("value")[0] as HTMLElement; + valueSymbol.hidden = true; + valueText.hidden = true; + item.style.height = valueSymbol.hidden ? "30px" : "50px"; + } +} diff --git a/src/hub/Tabs.ts b/src/hub/Tabs.ts index 10c9d08b..acd8006f 100644 --- a/src/hub/Tabs.ts +++ b/src/hub/Tabs.ts @@ -2,6 +2,8 @@ import { TabGroupState } from "../shared/HubState"; import TabType, { getDefaultTabTitle, getTabIcon, TIMELINE_VIZ_TYPES } from "../shared/TabType"; import { UnitConversionPreset } from "../shared/units"; import ScrollSensor from "./ScrollSensor"; +import SourceList from "./SourceList"; +import { OdometryConfig } from "../shared/SourceListConfig"; import TabController from "./TabController"; import ConsoleController from "./tabControllers/ConsoleController"; import DocumentationController from "./tabControllers/DocumentationController"; @@ -34,7 +36,6 @@ export default class Tabs { private tabList: { type: TabType; title: string; - controller: TabController; titleElement: HTMLElement; contentElement: HTMLElement; }[] = []; @@ -116,54 +117,51 @@ export default class Tabs { this.SHADOW_LEFT.style.opacity = Math.floor(this.TAB_BAR.scrollLeft) <= 0 ? "0" : "1"; this.SHADOW_RIGHT.style.opacity = Math.ceil(this.TAB_BAR.scrollLeft) >= this.TAB_BAR.scrollWidth - this.TAB_BAR.clientWidth ? "0" : "1"; - this.tabList[this.selectedTab].controller.periodic(); this.scrollSensor.periodic(); window.requestAnimationFrame(periodic); }; window.requestAnimationFrame(periodic); + + let sourceListRoot = (document.getElementsByClassName("tab-content")[0] as HTMLElement) + .firstElementChild as HTMLElement; + new SourceList(sourceListRoot, OdometryConfig); } /** Returns the current state. */ saveState(): TabGroupState { return { selected: this.selectedTab, - tabs: this.tabList.map((tab) => { - let state = tab.controller.saveState(); - if (tab.type !== TabType.Documentation) { - state.title = tab.title; - } - return state; - }) + tabs: [] }; } /** Restores to the provided state. */ restoreState(state: TabGroupState) { - this.tabList.forEach((tab) => { - this.VIEWER.removeChild(tab.contentElement); - }); - this.tabList = []; - this.selectedTab = 0; - state.tabs.forEach((tabState, index) => { - this.addTab(tabState.type); - if (tabState.title) this.renameTab(index, tabState.title); - this.tabList[index].controller.restoreState(tabState); - }); - this.selectedTab = state.selected >= this.tabList.length ? this.tabList.length - 1 : state.selected; - this.updateElements(); + // this.tabList.forEach((tab) => { + // this.VIEWER.removeChild(tab.contentElement); + // }); + // this.tabList = []; + // this.selectedTab = 0; + // state.tabs.forEach((tabState, index) => { + // this.addTab(tabState.type); + // if (tabState.title) this.renameTab(index, tabState.title); + // // this.tabList[index].controller.restoreState(tabState); + // }); + // this.selectedTab = state.selected >= this.tabList.length ? this.tabList.length - 1 : state.selected; + // this.updateElements(); } /** Refresh based on new log data. */ refresh() { this.tabList.forEach((tab) => { - tab.controller.refresh(); + // tab.controller.refresh(); }); } /** Refresh based on a new set of assets. */ newAssets() { this.tabList.forEach((tab) => { - tab.controller.newAssets(); + // tab.controller.newAssets(); }); } @@ -171,9 +169,9 @@ export default class Tabs { getActiveFields(): Set { let activeFields = new Set(); this.tabList.forEach((tab) => { - tab.controller.getActiveFields().forEach((field) => { - activeFields.add(field); - }); + // tab.controller.getActiveFields().forEach((field) => { + // activeFields.add(field); + // }); }); return activeFields; } @@ -189,70 +187,70 @@ export default class Tabs { } } - // Add tab - let contentElement: HTMLElement; - let controller: TabController; - switch (type) { - case TabType.Documentation: - contentElement = this.CONTENT_TEMPLATES.children[0].cloneNode(true) as HTMLElement; - controller = new DocumentationController(contentElement); - break; - case TabType.LineGraph: - contentElement = this.CONTENT_TEMPLATES.children[1].cloneNode(true) as HTMLElement; - controller = new LineGraphController(contentElement); - break; - case TabType.Table: - contentElement = this.CONTENT_TEMPLATES.children[2].cloneNode(true) as HTMLElement; - controller = new TableController(contentElement); - break; - case TabType.Console: - contentElement = this.CONTENT_TEMPLATES.children[3].cloneNode(true) as HTMLElement; - controller = new ConsoleController(contentElement); - break; - case TabType.Statistics: - contentElement = this.CONTENT_TEMPLATES.children[4].cloneNode(true) as HTMLElement; - controller = new StatisticsController(contentElement); - break; - case TabType.Odometry: - contentElement = this.CONTENT_TEMPLATES.children[5].cloneNode(true) as HTMLElement; - contentElement.appendChild(this.CONTENT_TEMPLATES.children[6].cloneNode(true)); - controller = new OdometryController(contentElement); - break; - case TabType.ThreeDimension: - contentElement = this.CONTENT_TEMPLATES.children[5].cloneNode(true) as HTMLElement; - contentElement.appendChild(this.CONTENT_TEMPLATES.children[7].cloneNode(true)); - controller = new ThreeDimensionController(contentElement); - break; - case TabType.Video: - contentElement = this.CONTENT_TEMPLATES.children[5].cloneNode(true) as HTMLElement; - contentElement.appendChild(this.CONTENT_TEMPLATES.children[8].cloneNode(true)); - controller = new VideoController(contentElement); - break; - case TabType.Joysticks: - contentElement = this.CONTENT_TEMPLATES.children[5].cloneNode(true) as HTMLElement; - contentElement.appendChild(this.CONTENT_TEMPLATES.children[9].cloneNode(true)); - controller = new JoysticksController(contentElement); - break; - case TabType.Swerve: - contentElement = this.CONTENT_TEMPLATES.children[5].cloneNode(true) as HTMLElement; - contentElement.appendChild(this.CONTENT_TEMPLATES.children[10].cloneNode(true)); - controller = new SwerveController(contentElement); - break; - case TabType.Mechanism: - contentElement = this.CONTENT_TEMPLATES.children[5].cloneNode(true) as HTMLElement; - contentElement.appendChild(this.CONTENT_TEMPLATES.children[11].cloneNode(true)); - controller = new MechanismController(contentElement); - break; - case TabType.Points: - contentElement = this.CONTENT_TEMPLATES.children[5].cloneNode(true) as HTMLElement; - contentElement.appendChild(this.CONTENT_TEMPLATES.children[12].cloneNode(true)); - controller = new PointsController(contentElement); - break; - case TabType.Metadata: - contentElement = this.CONTENT_TEMPLATES.children[13].cloneNode(true) as HTMLElement; - controller = new MetadataController(contentElement); - break; - } + // // Add tab + // let contentElement: HTMLElement; + // let controller: TabController; + // switch (type) { + // case TabType.Documentation: + // contentElement = this.CONTENT_TEMPLATES.children[0].cloneNode(true) as HTMLElement; + // controller = new DocumentationController(contentElement); + // break; + // case TabType.LineGraph: + // contentElement = this.CONTENT_TEMPLATES.children[1].cloneNode(true) as HTMLElement; + // controller = new LineGraphController(contentElement); + // break; + // case TabType.Table: + // contentElement = this.CONTENT_TEMPLATES.children[2].cloneNode(true) as HTMLElement; + // controller = new TableController(contentElement); + // break; + // case TabType.Console: + // contentElement = this.CONTENT_TEMPLATES.children[3].cloneNode(true) as HTMLElement; + // controller = new ConsoleController(contentElement); + // break; + // case TabType.Statistics: + // contentElement = this.CONTENT_TEMPLATES.children[4].cloneNode(true) as HTMLElement; + // controller = new StatisticsController(contentElement); + // break; + // case TabType.Odometry: + // contentElement = this.CONTENT_TEMPLATES.children[5].cloneNode(true) as HTMLElement; + // contentElement.appendChild(this.CONTENT_TEMPLATES.children[6].cloneNode(true)); + // controller = new OdometryController(contentElement); + // break; + // case TabType.ThreeDimension: + // contentElement = this.CONTENT_TEMPLATES.children[5].cloneNode(true) as HTMLElement; + // contentElement.appendChild(this.CONTENT_TEMPLATES.children[7].cloneNode(true)); + // controller = new ThreeDimensionController(contentElement); + // break; + // case TabType.Video: + // contentElement = this.CONTENT_TEMPLATES.children[5].cloneNode(true) as HTMLElement; + // contentElement.appendChild(this.CONTENT_TEMPLATES.children[8].cloneNode(true)); + // controller = new VideoController(contentElement); + // break; + // case TabType.Joysticks: + // contentElement = this.CONTENT_TEMPLATES.children[5].cloneNode(true) as HTMLElement; + // contentElement.appendChild(this.CONTENT_TEMPLATES.children[9].cloneNode(true)); + // controller = new JoysticksController(contentElement); + // break; + // case TabType.Swerve: + // contentElement = this.CONTENT_TEMPLATES.children[5].cloneNode(true) as HTMLElement; + // contentElement.appendChild(this.CONTENT_TEMPLATES.children[10].cloneNode(true)); + // controller = new SwerveController(contentElement); + // break; + // case TabType.Mechanism: + // contentElement = this.CONTENT_TEMPLATES.children[5].cloneNode(true) as HTMLElement; + // contentElement.appendChild(this.CONTENT_TEMPLATES.children[11].cloneNode(true)); + // controller = new MechanismController(contentElement); + // break; + // case TabType.Points: + // contentElement = this.CONTENT_TEMPLATES.children[5].cloneNode(true) as HTMLElement; + // contentElement.appendChild(this.CONTENT_TEMPLATES.children[12].cloneNode(true)); + // controller = new PointsController(contentElement); + // break; + // case TabType.Metadata: + // contentElement = this.CONTENT_TEMPLATES.children[13].cloneNode(true) as HTMLElement; + // controller = new MetadataController(contentElement); + // break; + // } // Create title element let titleElement = document.createElement("div"); @@ -266,16 +264,16 @@ export default class Tabs { this.tabList.splice(this.selectedTab + 1, 0, { type: type, title: getDefaultTabTitle(type), - controller: controller, + // controller: controller, titleElement: titleElement, - contentElement: contentElement + contentElement: document.createElement("div") }); this.selectedTab += 1; - this.VIEWER.appendChild(contentElement); - controller.periodic(); // Some controllers need to initialize by running a periodic cycle while visible - if (TIMELINE_VIZ_TYPES.includes(type)) { - (controller as TimelineVizController).setTitle(getDefaultTabTitle(type)); - } + // this.VIEWER.appendChild(contentElement); + // controller.periodic(); // Some controllers need to initialize by running a periodic cycle while visible + // if (TIMELINE_VIZ_TYPES.includes(type)) { + // (controller as TimelineVizController).setTitle(getDefaultTabTitle(type)); + // } this.updateElements(); } @@ -283,9 +281,9 @@ export default class Tabs { close(index: number) { if (index < 1 || index > this.tabList.length - 1) return; if (TIMELINE_VIZ_TYPES.includes(this.tabList[index].type)) { - (this.tabList[index].controller as TimelineVizController).stopPeriodic(); + // (this.tabList[index].controller as TimelineVizController).stopPeriodic(); } - this.VIEWER.removeChild(this.tabList[index].contentElement); + // this.VIEWER.removeChild(this.tabList[index].contentElement); this.tabList.splice(index, 1); if (this.selectedTab > index) this.selectedTab--; if (this.selectedTab > this.tabList.length - 1) this.selectedTab = this.tabList.length - 1; @@ -322,42 +320,42 @@ export default class Tabs { tab.title = name; tab.titleElement.innerText = getTabIcon(tab.type) + " " + name; if (TIMELINE_VIZ_TYPES.includes(tab.type)) { - (tab.controller as TimelineVizController).setTitle(name); + // (tab.controller as TimelineVizController).setTitle(name); } } /** Adds the enabled field to the discrete legend on the selected line graph. */ addDiscreteEnabled() { if (this.tabList[this.selectedTab].type === TabType.LineGraph) { - (this.tabList[this.selectedTab].controller as LineGraphController).addDiscreteEnabled(); + // (this.tabList[this.selectedTab].controller as LineGraphController).addDiscreteEnabled(); } } /** Adjusts the locked range and unit conversion for an axis on the selected line graph. */ editAxis(legend: string, lockedRange: [number, number] | null, unitConversion: UnitConversionPreset) { if (this.tabList[this.selectedTab].type === TabType.LineGraph) { - (this.tabList[this.selectedTab].controller as LineGraphController).editAxis(legend, lockedRange, unitConversion); + // (this.tabList[this.selectedTab].controller as LineGraphController).editAxis(legend, lockedRange, unitConversion); } } /** Clear the fields for an axis on the selected line graph. */ clearAxis(legend: string) { if (this.tabList[this.selectedTab].type === TabType.LineGraph) { - (this.tabList[this.selectedTab].controller as LineGraphController).clearAxis(legend); + // (this.tabList[this.selectedTab].controller as LineGraphController).clearAxis(legend); } } /** Switches the selected camera for the selected 3D field. */ set3DCamera(index: number) { if (this.tabList[this.selectedTab].type === TabType.ThreeDimension) { - (this.tabList[this.selectedTab].controller as ThreeDimensionController).set3DCamera(index); + // (this.tabList[this.selectedTab].controller as ThreeDimensionController).set3DCamera(index); } } /** Switches the orbit FOV for the selected 3D field. */ setFov(fov: number) { if (this.tabList[this.selectedTab].type === TabType.ThreeDimension) { - (this.tabList[this.selectedTab].controller as ThreeDimensionController).setFov(fov); + // (this.tabList[this.selectedTab].controller as ThreeDimensionController).setFov(fov); } } @@ -366,7 +364,8 @@ export default class Tabs { * and right arrow keys) */ isUnlockedVideoSelected(): boolean { if (this.tabList[this.selectedTab].type === TabType.Video) { - return !(this.tabList[this.selectedTab].controller as VideoController).isLocked(); + // return !(this.tabList[this.selectedTab].controller as VideoController).isLocked(); + return false; } else { return false; } @@ -376,7 +375,7 @@ export default class Tabs { processVideoData(data: any) { this.tabList.forEach((tab) => { if (tab.type === TabType.Video) { - (tab.controller as VideoController).processVideoData(data); + // (tab.controller as VideoController).processVideoData(data); } }); } diff --git a/src/hub/hub.ts b/src/hub/hub.ts index 7ebef7a7..7b42f435 100644 --- a/src/hub/hub.ts +++ b/src/hub/hub.ts @@ -5,6 +5,7 @@ import Log from "../shared/log/Log"; import { AKIT_TIMESTAMP_KEYS } from "../shared/log/LogUtil"; import NamedMessage from "../shared/NamedMessage"; import Preferences from "../shared/Preferences"; +import { SourceListItemState } from "../shared/SourceListConfig"; import { clampValue, htmlEncode, scaleValue } from "../shared/util"; import { HistoricalDataSource, HistoricalDataSourceStatus } from "./dataSources/HistoricalDataSource"; import { LiveDataSource, LiveDataSourceStatus } from "./dataSources/LiveDataSource"; @@ -17,6 +18,7 @@ import PhoenixDiagnosticsSource from "./dataSources/PhoenixDiagnosticsSource"; import RLOGServerSource from "./dataSources/rlog/RLOGServerSource"; import Selection from "./Selection"; import Sidebar from "./Sidebar"; +import SourceList from "./SourceList"; import Tabs from "./Tabs"; import WorkerManager from "./WorkerManager"; @@ -589,6 +591,12 @@ function handleMainMessage(message: NamedMessage) { window.tabs.renameTab(message.data.index, message.data.name); break; + case "source-list-type-response": + let uuid: string = message.data.uuid; + let state: SourceListItemState = message.data.state; + SourceList.promptCallbacks[uuid](state); + break; + case "add-discrete-enabled": window.tabs.addDiscreteEnabled(); break; diff --git a/src/main/main.ts b/src/main/main.ts index 10b66703..54e832ec 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -62,6 +62,7 @@ import { VideoProcessor } from "./VideoProcessor"; import { getAssetDownloadStatus, startAssetDownload } from "./assetsDownload"; import { convertLegacyAssets, createAssetFolders, getUserAssetsPath, loadAssets } from "./assetsUtil"; import { checkHootIsPro, convertHoot, copyOwlet } from "./hootUtil"; +import { SourceListConfig, SourceListItemState } from "../shared/SourceListConfig"; // Global variables let hubWindows: BrowserWindow[] = []; // Ordered by last focus time (recent first) @@ -450,6 +451,117 @@ function handleHubMessage(window: BrowserWindow, message: NamedMessage) { newTabPopup(window); break; + case "source-list-type-prompt": + let uuid: string = message.data.uuid; + let config: SourceListConfig = message.data.config; + let state: SourceListItemState = message.data.state; + let coordinates: [number, number] = message.data.coordinates; + const menu = new Menu(); + + let respond = () => { + sendMessage(window, "source-list-type-response", { + uuid: uuid, + state: state + }); + }; + + // Add options + let currentTypeConfig = config.types.find((typeConfig) => typeConfig.key === state.type)!; + if (currentTypeConfig.options.length === 1) { + let optionConfig = currentTypeConfig.options[0]; + optionConfig.values.forEach((optionValue) => { + menu.append( + new MenuItem({ + label: optionValue.display, + type: "radio", + checked: optionValue.key === state.options[optionConfig.key], + click() { + state.options[optionConfig.key] = optionValue.key; + respond(); + } + }) + ); + }); + } else { + currentTypeConfig.options.forEach((optionConfig) => { + menu.append( + new MenuItem({ + label: optionConfig.display, + submenu: optionConfig.values.map((optionValue) => { + return { + label: optionValue.display, + type: "radio", + checked: optionValue.key === state.options[optionConfig.key], + click() { + state.options[optionConfig.key] = optionValue.key; + respond(); + } + }; + }) + }) + ); + }); + } + + // Add type options + if (menu.items.length > 0) { + menu.append( + new MenuItem({ + type: "separator" + }) + ); + } + config.types.forEach((typeConfig) => { + if (typeConfig.sourceTypes.includes(state.logType) && typeConfig.parentType === currentTypeConfig.parentType) { + let current = state.type === typeConfig.key; + let optionConfig = current + ? undefined + : typeConfig.options.find((optionConfig) => optionConfig.key === typeConfig.initialSelectionOption); + menu.append( + new MenuItem({ + label: typeConfig.display, + type: current ? "checkbox" : optionConfig !== undefined ? "submenu" : "normal", + checked: current, + submenu: + optionConfig === undefined + ? undefined + : optionConfig.values.map((optionValue) => { + return { + label: optionValue.display, + click() { + state.type = typeConfig.key; + state.options = {}; + typeConfig.options.forEach((optionConfig) => { + state.options[optionConfig.key] = optionConfig.values[0].key; + }); + state.options[typeConfig.initialSelectionOption!] = optionValue.key; + respond(); + } + }; + }), + click: + optionConfig !== undefined + ? undefined + : () => { + state.type = typeConfig.key; + state.options = {}; + typeConfig.options.forEach((optionConfig) => { + state.options[optionConfig.key] = optionConfig.values[0].key; + }); + respond(); + } + }) + ); + } + }); + + menu.popup({ + window: window, + x: coordinates[0], + y: coordinates[1] + }); + break; + case "ask-edit-axis": let legend: string = message.data.legend; const editAxisMenu = new Menu(); @@ -560,11 +672,11 @@ function handleHubMessage(window: BrowserWindow, message: NamedMessage) { break; case "update-satellite": - let uuid = message.data.uuid; + let satelliteUUID = message.data.uuid; let command = message.data.command; let title = message.data.title; - if (uuid in satelliteWindows) { - satelliteWindows[uuid].forEach((satellite) => { + if (satelliteUUID in satelliteWindows) { + satelliteWindows[satelliteUUID].forEach((satellite) => { if (satellite.isVisible()) { sendMessage(satellite, "render", { command: command, title: title }); } diff --git a/src/shared/SourceListConfig.ts b/src/shared/SourceListConfig.ts new file mode 100644 index 00000000..0cd00176 --- /dev/null +++ b/src/shared/SourceListConfig.ts @@ -0,0 +1,295 @@ +export type SourceListConfig = { + title: string; + types: SourceListTypeConfig[]; +}; + +export type SourceListTypeConfig = { + key: string; + display: string; + symbol: string; + showInTypeName: boolean; + color: string; // Option key or hex (starting with #) + darkColor?: string; + sourceTypes: string[]; + parentType?: string; + + // If only one option, show without submenu + options: SourceListOptionConfig[]; + initialSelectionOption?: string; +}; + +export type SourceListOptionConfig = { + key: string; + display: string; + showInTypeName: boolean; + values: { + key: string; + display: string; + }[]; +}; + +export type SourceListState = SourceListItemState[]; + +export type SourceListItemState = { + type: string; + logKey: string; + logType: string; + visible: boolean; + options: { [key: string]: string }; +}; + +export const OdometryConfig: SourceListConfig = { + title: "Poses", + types: [ + { + key: "robot", + display: "Robot", + symbol: "location.fill", + showInTypeName: false, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["NumberArray", "Pose2d", "Pose2d[]", "Transform2d", "Transform2d[]"], + options: [ + { + key: "model", + display: "Model", + showInTypeName: true, + values: [ + { key: "Presto", display: "Presto" }, + { key: "KitBot", display: "KitBot" } + ] + } + ], + initialSelectionOption: "model" + }, + { + key: "ghost", + display: "Ghost", + symbol: "location.fill.viewfinder", + showInTypeName: true, + color: "color", + sourceTypes: ["NumberArray", "Pose2d", "Pose2d[]", "Transform2d", "Transform2d[]", "ZebraTranslation"], + options: [ + { + key: "model", + display: "Model", + showInTypeName: true, + values: [ + { key: "Presto", display: "Presto" }, + { key: "KitBot", display: "KitBot" } + ] + }, + { + key: "color", + display: "Color", + showInTypeName: false, + values: [ + { key: "#ff0000", display: "Red" }, + { key: "#00ff00", display: "Green" }, + { key: "#0000ff", display: "Blue" }, + { key: "#ffff00", display: "Yellow" }, + { key: "#ff00ff", display: "Magenta" }, + { key: "#00ffff", display: "Cyan" } + ] + } + ], + initialSelectionOption: "model" + }, + { + key: "component", + display: "Component", + symbol: "puzzlepiece.extension.fill", + showInTypeName: true, + color: "#888888", + sourceTypes: ["NumberArray", "Pose2d", "Pose2d[]", "Transform2d", "Transform2d[]"], + options: [], + parentType: "robot" + }, + { + key: "camera", + display: "Camera", + symbol: "camera.fill", + showInTypeName: true, + color: "#888888", + sourceTypes: ["NumberArray", "Pose2d", "Transform2d"], + options: [], + parentType: "robot" + }, + { + key: "vision", + display: "Vision Target", + symbol: "scope", + showInTypeName: true, + color: "#00bb00", + sourceTypes: [ + "NumberArray", + "Pose2d", + "Pose2d[]", + "Transform2d", + "Transform2d[]", + "Translation2d", + "Translation2d[]" + ], + options: [] + }, + { + key: "trajectory", + display: "Trajectory", + symbol: "point.bottomleft.forward.to.point.topright.scurvepath.fill", + showInTypeName: true, + color: "#ff8800", + sourceTypes: ["NumberArray", "Pose2d[]", "Transform2d[]", "Translation2d[]", "Trajectory"], + options: [] + }, + { + key: "heatmap", + display: "Heatmap", + symbol: "map.fill", + showInTypeName: true, + color: "#ff0000", + sourceTypes: [ + "NumberArray", + "Pose2d", + "Pose2d[]", + "Transform2d", + "Transform2d[]", + "Translation2d", + "Translation2d[]" + ], + options: [ + { + key: "samples", + display: "Samples", + showInTypeName: false, + values: [ + { key: "enabled", display: "Enabled Only" }, + { key: "full", display: "Full Log" } + ] + } + ] + }, + { + key: "arrow", + display: "Arrow", + symbol: "arrow.up.circle", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["NumberArray", "Pose2d", "Pose2d[]", "Transform2d", "Transform2d[]"], + options: [ + { + key: "position", + display: "Position", + showInTypeName: false, + values: [ + { key: "center", display: "Center" }, + { key: "back", display: "Back" }, + { key: "front", display: "Front" } + ] + } + ] + }, + { + key: "zebra", + display: "Zebra Marker", + symbol: "mappin.circle.fill", + showInTypeName: true, + color: "#000000", + darkColor: "#ffffff", + sourceTypes: ["ZebraTranslation"], + options: [] + } + ] +}; + +// const LineGraphConfig: SourceListConfig = { +// name: "Left Axis", +// types: [ +// { +// key: "stepped", +// display: "Stepped", +// symbol: "circle.fill", +// showInTypeName: false, +// color: "color", +// options: [ +// { +// key: "color", +// display: "Color", +// showInTypeName: false, +// values: [ +// { key: "#ff0000", display: "Red" }, +// { key: "#00ff00", display: "Green" }, +// { key: "#0000ff", display: "Blue" } +// ] +// }, +// { +// key: "thickness", +// display: "Thickness", +// showInTypeName: false, +// values: [ +// { key: "normal", display: "Normal" }, +// { key: "bold", display: "Bold" }, +// { key: "verybold", display: "Very Bold" } +// ] +// } +// ] +// }, +// { +// key: "smooth", +// display: "Smooth", +// symbol: "circle.circle.fill", +// showInTypeName: false, +// color: "color", +// options: [ +// { +// key: "color", +// display: "Color", +// showInTypeName: false, +// values: [ +// { key: "#ff0000", display: "Red" }, +// { key: "#00ff00", display: "Green" }, +// { key: "#0000ff", display: "Blue" } +// ] +// }, +// { +// key: "thickness", +// display: "Thickness", +// showInTypeName: false, +// values: [ +// { key: "normal", display: "Normal" }, +// { key: "bold", display: "Bold" }, +// { key: "verybold", display: "Very Bold" } +// ] +// } +// ] +// }, +// { +// key: "points", +// display: "Points", +// symbol: "circle.dotted.circle.fill", +// showInTypeName: false, +// color: "color", +// options: [ +// { +// key: "color", +// display: "Color", +// showInTypeName: false, +// values: [ +// { key: "#ff0000", display: "Red" }, +// { key: "#00ff00", display: "Green" }, +// { key: "#0000ff", display: "Blue" } +// ] +// }, +// { +// key: "size", +// display: "Size", +// showInTypeName: false, +// values: [ +// { key: "normal", display: "Normal" }, +// { key: "large", display: "Large" } +// ] +// } +// ] +// } +// ] +// }; diff --git a/www/global.css b/www/global.css index eafe1891..cab5f986 100644 --- a/www/global.css +++ b/www/global.css @@ -50,6 +50,17 @@ button > img { filter: invert(32%) sepia(0%) saturate(60%) hue-rotate(224deg) brightness(100%) contrast(95%); } +button > object { + position: absolute; + max-height: 100%; + max-width: 100%; + left: 50%; + top: 50%; + transform: translate(-50%, -50%) scale(75%); + + pointer-events: none; +} + button.blurred > img, button:disabled > img { /* https://codepen.io/sosuke/pen/Pjoqqp */ diff --git a/www/hub.css b/www/hub.css index 73c673e1..38b0d586 100644 --- a/www/hub.css +++ b/www/hub.css @@ -707,195 +707,112 @@ div.tab-content { right: 0px; } -div.tab-centered { +/* Source list */ + +div.source-list { position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); + top: 10px; + left: 10px; + width: 50%; + height: 50%; - text-align: center; - font-style: italic; + border: 1px solid black; } -/* Documentation */ - -div.documentation-container { +div.source-list div.title { position: absolute; - left: 0px; top: 0px; - width: 100%; - height: 100%; - overflow: auto; -} - -div.documentation-text { - padding: 15px; - user-select: text; - overflow-wrap: normal; -} - -div.documentation-text h1 { - margin: 0px 0px 0px 0px; -} - -div.documentation-text h2 { - margin: 20px 0px 0px 0px; -} - -div.documentation-text h3 { - margin: 20px 0px 0px 0px; -} - -div.documentation-text p { - margin: 12px 0px 12px 0px; -} - -div.documentation-text li { - margin: 5px 0px 5px 0px; -} - -div.documentation-text a { - text-decoration: none; -} + height: 30px; + left: 35px; + right: 35px; -div.documentation-text a:hover { - text-decoration: underline; + text-align: center; + font-size: 16px; + line-height: 30px; + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -div.documentation-text code { - font-size: 17px; +div.source-list div.list { + position: absolute; + top: 30px; + bottom: 0px; + left: 0px; + right: 0px; + overflow-x: hidden; + overflow-y: auto; } -div.documentation-text pre > code { - display: block; - font-size: 14px; - background-color: #eee; - padding: 8px; - overflow-x: auto; +div.source-list div.item { + position: relative; + width: 100%; + height: 30px; + border-top: 1px solid #eee; } -div.documentation-text img, -div.documentation-text video { - max-width: calc(100% - 50px); - max-height: 50vh; - margin: 10px 15px 10px 15px; +div.source-list div.item:first-child { + border-top: none; } -div.documentation-text blockquote { - background-color: #eee; - margin: 12px 0px 12px 0px; - padding: 10px; +div.source-list div.item:last-child { + border-bottom: 1px solid #eee; } -div.documentation-text blockquote p { - margin: 0px; +div.source-list div.item.child { + border-top: none; } @media (prefers-color-scheme: dark) { - div.documentation-text pre > code { - background-color: #111; - } - - div.documentation-text blockquote { - background-color: #111; + div.source-list div.item { + border-top: 1px solid #333; } -} - -/* Line graph */ - -div.legend-handle { - position: absolute; - left: 0%; - width: 100%; - bottom: var(--legend-height); - height: 6px; - transform: translateY(50%); - cursor: row-resize; - - z-index: 8; - opacity: 0; - background-color: #ddd; -} - -div.legend-handle:hover { - display: initial; - opacity: 0.5; -} - -div.legend-left { - position: absolute; - left: 0%; - width: 33%; - bottom: 0px; - height: var(--legend-height); - overflow: auto; -} - -div.legend-discrete { - position: absolute; - left: 33%; - width: 33%; - bottom: 0px; - height: var(--legend-height); - overflow: auto; - border-left: 1px solid #555; - border-right: 1px solid #555; -} - -@media (prefers-color-scheme: dark) { - div.legend-discrete { - border-left: 1px solid #999; - border-right: 1px solid #999; + div.source-list div.item:last-child { + border-bottom: 1px solid #333; } } -div.legend-right { +div.source-list button { position: absolute; - left: 66%; - width: 34%; - bottom: 0px; - height: var(--legend-height); - overflow: auto; -} - -div.legend-drag-target { - background-color: lightgreen; - opacity: 25%; + width: 25px; + height: 25px; } -div.legend-item { - position: relative; - width: 100%; - height: 30px; +div.source-list button.type { + top: 15px; + left: 15px; + transform: translate(-50%, -50%); } -div.legend-item-with-value { - height: 45px; +div.source-list div.hidden button.type, +div.source-list div.hidden div.type-name, +div.source-list div.hidden div.key-container { + opacity: 0.4; } -div.legend-title { +div.source-list div.type-name { position: absolute; top: 0px; height: 30px; - left: 5px; - right: 35px; + left: 30px; + max-width: calc(100% - 30px - 55px); - text-align: center; - font-size: 16px; - line-height: 30px; font-weight: bold; + font-size: 14px; + line-height: 30px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -div.legend-key-container { +div.source-list div.key-container { position: absolute; top: 0px; height: 30px; - left: 30px; - max-width: calc(100% - 30px - 30px); + left: calc(33px + var(--type-width)); + max-width: calc(100% - 31px - var(--type-width) - 55px); text-align: right; direction: rtl; @@ -907,31 +824,23 @@ div.legend-key-container { text-overflow: ellipsis; } -span.legend-key { +div.source-list div.key-container span { unicode-bidi: plaintext; } -svg.legend-splotch { - position: absolute; - top: 15px; - left: 15px; - transform: translate(-50%, -50%); - - stroke: #555; -} - -button.legend-edit { - position: absolute; +div.source-list button.remove { top: 15px; right: 15px; transform: translate(50%, -50%); +} - width: 25px; - height: 25px; +div.source-list button.hide { + top: 15px; + right: 27.5px; + transform: translateY(-50%); } -img.legend-value-symbol { - display: none; +div.source-list img.value-symbol { position: absolute; top: 32px; left: 32px; @@ -942,8 +851,7 @@ img.legend-value-symbol { filter: invert(43%) sepia(0%) saturate(2564%) hue-rotate(1deg) brightness(110%) contrast(107%); } -div.legend-value { - display: none; +div.source-list div.value { position: absolute; top: 30px; height: 15px; @@ -961,1076 +869,11 @@ div.legend-value { text-overflow: ellipsis; } -div.legend-item-with-value img.legend-value-symbol, -div.legend-item-with-value div.legend-value { - display: initial; -} - -@media (prefers-color-scheme: dark) { - div.legend-handle { - background-color: #444; - } - - svg.legend-splotch { - stroke: #999; - } - - img.legend-value-symbol { - /* https://codepen.io/sosuke/pen/Pjoqqp */ - filter: invert(75%) sepia(12%) saturate(0%) hue-rotate(185deg) brightness(89%) contrast(90%); - } - - div.legend-value { - color: #aaa; - } -} - -div.line-graph-canvas-container { - position: absolute; - top: 0px; - bottom: var(--legend-height); - left: 0px; - right: 0px; -} - -canvas.line-graph-canvas { - position: absolute; - height: 100%; - width: 100%; -} - -div.line-graph-scroll { - position: absolute; - z-index: 9; - left: 0px; - right: 0px; - top: 8px; - bottom: 50px; - overflow: scroll; -} - -div.line-graph-scroll::-webkit-scrollbar { - display: none; -} - -div.line-graph-scroll-content { - width: 1000000px; - height: 1000000px; -} - -/* Table */ - -div.data-table-container { - position: absolute; - top: 0px; - bottom: 0px; - left: 0px; - right: 0px; - overflow: auto; -} - -table.data-table { - text-align: left; - white-space: nowrap; - border-collapse: separate; - border-spacing: 0; - font-size: 14px; - - border-style: hidden; -} - -table.data-table th { - position: sticky; - top: 0px; - height: 30px; - padding: 0px; - border-right: 1px solid #eee; - border-bottom: 1px solid #222; - - background-color: #fff; - z-index: 8; -} - -table.data-table th:first-child { - left: 0px; - min-width: 97px; - z-index: 10; -} - -table.data-table th:first-child input { - position: absolute; - left: 4px; - top: 4.5px; - height: 15px; - width: 56px; -} - -table.data-table th:first-child button { - position: absolute; - left: 70px; - top: 2.5px; - height: 25px; - width: 25px; -} - -table.data-table th:not(:first-child) { - min-width: 175px; -} - -div.data-table-key-container { - position: absolute; - left: 6px; - right: 30px; - top: 0px; - height: 30px; - line-height: 30px; - - direction: rtl; - overflow: hidden; - text-overflow: ellipsis; -} - -div.data-table-key-container span { - unicode-bidi: plaintext; -} - -button.data-table-key-delete { - position: absolute; - height: 25px; - width: 25px; - top: 2.5px; - right: 2px; -} - -table.data-table th:last-child { - border-right: none; -} - -table.data-table td { - height: 16px; - padding: 4px; - font-family: monospace; - user-select: text; - border-right: 1px solid #eee; - border-bottom: 1px solid #eee; -} - -table.data-table td:first-child { - position: sticky; - left: 0px; - text-align: right; - font-weight: bold; - - background-color: #fff; -} - -table.data-table td:last-child { - border-right: none; -} - -table.data-table tr:last-child td { - border-bottom: none; -} - -table.data-table tr.hovered td { - background-color: #ddd; -} - -table.data-table tr.selected td { - background-color: #aaa; -} - -@media (prefers-color-scheme: dark) { - table.data-table th { - border-right: 1px solid #333; - border-bottom: 1px solid #eee; - background-color: #222; - } - - table.data-table td { - border-right: 1px solid #333; - border-bottom: 1px solid #333; - } - - table.data-table td:first-child { - background-color: #222; - } - - table.data-table tr.hovered td { - background-color: #444; - } - - table.data-table tr.selected td { - background-color: #777; - } -} - -div.data-table-drag-highlight { +div.source-list div.drag-highlight { position: absolute; z-index: 11; - width: 25px; - top: 0px; - bottom: 0px; background-color: lightgreen; opacity: 25%; -} - -/* Console */ - -div.console-table-drag-highlight { - position: absolute; - left: 0px; - top: 0px; - width: 100%; - height: 100%; - z-index: 11; - background-color: lightgreen; - opacity: 25%; -} - -div.console-table-container { - position: absolute; - top: 0px; - bottom: 0px; - left: 0px; - right: 0px; - overflow-x: hidden; - overflow-y: auto; -} - -table.console-table { - width: 100%; - table-layout: fixed; - - border-collapse: separate; - border-spacing: 0; - border-style: hidden; - margin-bottom: 6px; -} - -table.console-table th { - position: sticky; - top: 0px; - height: 30px; - padding: 0px; - border-right: 1px solid #eee; - border-bottom: 1px solid #222; - font-size: 14px; - - background-color: #fff; - z-index: 8; -} - -table.console-table th:first-child { - width: 97px; - z-index: 10; -} - -table.console-table th:first-child input { - position: absolute; - left: 4px; - top: 4.5px; - height: 15px; - width: 56px; -} - -table.console-table th:first-child button { - position: absolute; - left: 70px; - top: 2.5px; - height: 25px; - width: 25px; -} - -table.console-table th:not(:first-child) { - border-right: none; -} - -table.console-table th:not(:first-child) div { - position: absolute; - left: 0px; - top: 0px; - height: 30px; - right: 200px; - padding-left: 5px; - overflow: hidden; - font-size: 14px; - line-height: 30px; - text-align: left; - white-space: nowrap; -} - -table.console-table th:not(:first-child) input { - position: absolute; - top: 50%; - right: 5px; - height: 15px; - width: 180px; - padding-top: 9px; - padding-bottom: 9px; - transform: translateY(-50%); -} - -table.console-table th:not(:first-child) input::-webkit-search-cancel-button { - -webkit-appearance: none; - height: 1em; - width: 1em; - background: url("symbols/xmark.circle.fill.svg") no-repeat 50% 50%; - background-size: contain; -} - -table.console-table td { - padding: 0px 6px 0px 6px; - user-select: text; - vertical-align: top; - font-family: monospace; - font-size: 12px; - line-height: 16px; - overflow-wrap: break-word; - word-break: break-all; - white-space: break-spaces; - tab-size: 4; -} - -table.console-table td:first-child { - text-align: right; - font-weight: bold; - user-select: none; -} - -table.console-table td:last-child { - border-right: none; -} - -table.console-table tr.hovered td { - background-color: #eee; -} - -table.console-table tr.selected td { - background-color: #e3e3e3; -} - -@media (prefers-color-scheme: dark) { - table.console-table th { - border-right: 1px solid #333; - border-bottom: 1px solid #eee; - background-color: #222; - } - - table.console-table td:first-child { - background-color: #222; - } - - table.console-table tr.hovered td { - background-color: #333; - } - - table.console-table tr.selected td { - background-color: #3e3e3e; - } -} - -/* Statistics */ - -table.stats-config, -table.stats-values { - border-collapse: separate; - border-spacing: 0; - border: 1px solid #555; -} - -table.stats-config td, -table.stats-values td { - border: 1px solid #eee; - overflow: hidden; -} - -@media (prefers-color-scheme: dark) { - table.stats-config, - table.stats-values { - border: 1px solid #999; - } - - table.stats-config td, - table.stats-values td { - border: 1px solid #333; - } -} - -table.stats-config { - position: absolute; - table-layout: fixed; - left: 10px; - width: calc(100% - 20px); - top: 10px; -} - -table.stats-config td { - position: relative; - word-wrap: break-word; - text-align: center; - padding: 5px; -} - -table.stats-config tr:last-child td { - line-height: 25px; -} - -table.stats-config span.label { - font-weight: 600; -} - -table.stats-config span.field-name { - font-family: monospace; - font-size: 14px; -} - -table.stats-config input[type="number"] { - width: 50px; -} - -div.stats-values-container { - position: absolute; - left: 10px; - bottom: 0px; - overflow-y: auto; -} - -table.stats-values { - width: 100%; - margin-top: 10px; - margin-bottom: 10px; -} - -table.stats-values td { - word-wrap: break-word; - padding: 2px 6px 2px 6px; -} - -table.stats-values tr.title td { - font-weight: 600; - font-size: 14px; -} - -table.stats-values tr.section td { - text-align: center; - font-weight: 600; - font-size: 14px; -} - -table.stats-values tr.section td { - border-top: 1px solid #555; -} - -@media (prefers-color-scheme: dark) { - table.stats-values tr.section td { - border-top: 1px solid #999; - } -} - -table.stats-values tr.values td:first-child { - text-align: right; - font-size: 12px; - width: 100px; -} - -table.stats-values tr.values td:not(:first-child) { - user-select: text; - font-family: monospace; - font-size: 14px; -} - -div.stats-histogram-container { - position: absolute; - right: 10px; - bottom: 10px; - overflow: hidden; -} - -div.stats-drag-highlight { - position: absolute; - z-index: 11; - background-color: lightgreen; - opacity: 25%; -} - -/* Timline visualizers */ - -div.timeline-viz-timeline-container { - position: absolute; - top: 0px; - left: 38px; - right: 71px; - height: 30px; -} - -input.timeline-viz-timeline-slider { - position: absolute; - margin: 0px; - left: 0px; - width: 100%; - top: 0px; - height: 23px; - - appearance: none; - background-color: #aaa; -} - -input.timeline-viz-timeline-slider::-webkit-slider-thumb { - appearance: none; - height: 23px; - width: 4px; - background: black; - cursor: pointer; -} - -input.timeline-viz-timeline-slider:disabled::-webkit-slider-thumb { - cursor: initial; -} - -div.timeline-viz-timeline-marker-container { - position: absolute; - bottom: 0px; - height: 7px; - left: 0px; - right: 0px; - background: red; -} - -div.timeline-viz-timeline-marker-container div { - position: absolute; - height: 100%; - background-color: lightgreen; -} - -div.timeline-viz-timeline-label { - position: absolute; - top: 30px; - left: 0px; - transform: translateX(-50%); - z-index: 10; - font-size: 12px; - padding: 8px; user-select: none; pointer-events: none; - - background-color: #ffffff; - border-radius: 6px; - box-shadow: 0px 0px 3px 2px #00000020; - opacity: 0; - transition: opacity 0.1s; -} - -div.timeline-viz-timeline-label.show { - opacity: 1; -} - -@media (prefers-color-scheme: dark) { - div.timeline-viz-timeline-label { - box-shadow: none; - border: 0.5px solid rgba(255, 255, 255, 0.3); - background-color: #222222; - } -} - -button.timeline-viz-reset-button { - position: absolute; - width: 28px; - height: 28px; - top: 1px; - left: 5px; -} - -button.timeline-viz-hide-button, -button.timeline-viz-show-button { - position: absolute; - width: 28px; - height: 28px; - top: 1px; - right: 38px; -} - -button.timeline-viz-popup-button { - position: absolute; - width: 28px; - height: 28px; - top: 1px; - right: 5px; -} - -div.timeline-viz-drag-highlight { - position: absolute; - z-index: 11; - background-color: lightgreen; - opacity: 25%; -} - -table.timeline-viz-config { - position: absolute; - left: 0px; - bottom: 0px; - width: 100%; - table-layout: fixed; - border-collapse: separate; - border-spacing: 0; - border-top: 1px solid #555; -} - -table.timeline-viz-config th { - position: relative; - text-align: center; - font-size: 16px; - font-weight: bold; - padding: 5px; - border-right: 1px solid #555; - border-bottom: 1px solid #555; -} - -table.timeline-viz-config td { - position: relative; - word-wrap: break-word; - padding: 5px; - border-right: 1px solid #555; - border-bottom: 1px solid #eee; - overflow: hidden; -} - -table.timeline-viz-config td:last-child, -table.timeline-viz-config th:last-child { - border-right: none; -} - -table.timeline-viz-config tr:last-child td { - border-bottom: none; - padding-bottom: 6px; -} - -table.timeline-viz-config td.list { - padding: 0px; - vertical-align: top; - border-bottom: none; -} - -table.timeline-viz-config td.list div.list-content { - left: 0%; - top: 0%; - width: 100%; - max-height: 25vh; - overflow-x: hidden; - overflow-y: auto; - padding: 0px; - vertical-align: top; -} - -table.timeline-viz-config td.list div.list-shadow-top, -table.timeline-viz-config td.list div.list-shadow-bottom { - position: absolute; - left: 0%; - width: 100%; - height: 30px; - pointer-events: none; - opacity: 0; - transition: opacity 0.2s ease-in-out; -} - -table.timeline-viz-config td.list div.list-shadow-top { - top: 0%; - background-image: linear-gradient(to bottom, white, transparent); -} - -table.timeline-viz-config td.list div.list-shadow-bottom { - bottom: 0%; - background-image: linear-gradient(to top, white, transparent); -} - -@media (prefers-color-scheme: dark) { - table.timeline-viz-config td.list div.list-shadow-top { - background-image: linear-gradient(to bottom, #222, transparent); - } - - table.timeline-viz-config td.list div.list-shadow-bottom { - background-image: linear-gradient(to top, #222, transparent); - } -} - -table.timeline-viz-config div.list-filler { - position: absolute; - left: 0%; - width: 100%; - top: 50%; - transform: translateY(-50%); - text-align: center; - font-family: monospace; - font-size: 14px; -} - -table.timeline-viz-config div.list-item { - padding: 5px; - border-bottom: 1px solid #eee; -} - -table.timeline-viz-config span.label { - font-weight: 600; -} - -table.timeline-viz-config span.field-name { - font-family: monospace; - font-size: 14px; -} - -table.timeline-viz-config a.credit-link { - font-size: 14px; -} - -table.timeline-viz-config input[type="number"] { - width: 75px; -} - -table.timeline-viz-config button { - height: 30px; - width: 30px; -} - -@media (prefers-color-scheme: dark) { - table.timeline-viz-config { - border-top: 1px solid #999; - } - - table.timeline-viz-config th { - border-right: 1px solid #999; - border-bottom: 1px solid #999; - } - - table.timeline-viz-config td { - border-right: 1px solid #999; - border-bottom: 1px solid #333; - } - - table.timeline-viz-config div.list-item { - border-bottom: 1px solid #333; - } -} - -div.odometry-canvas-container, -div.three-dimension-canvas-container, -canvas.joysticks-canvas { - position: absolute; - top: 30px; - height: calc(100% - 30px - var(--bottom-margin)); - left: 0px; - width: 100%; -} - -div.odometry-canvas-container canvas { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); -} - -div.odometry-canvas-container div { - position: absolute; - left: 0%; - width: 100%; - top: 0%; - height: 100%; -} - -div.three-dimension-annotations { - pointer-events: none; -} - -div.three-dimension-annotations > div { - text-align: center; - font-weight: bold; - color: #ffffff; - text-shadow: 0px 0px 10px black; -} - -canvas.three-dimension-canvas:not(.fixed), -div.three-dimension-annotations:not(.fixed) { - position: absolute; - left: 0%; - top: 0%; - width: 100%; - height: 100%; -} - -canvas.three-dimension-canvas.fixed, -div.three-dimension-annotations.fixed { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); -} - -canvas.three-dimension-canvas.fixed { - border: 1px solid #555; -} - -div.three-dimension-alert { - position: absolute; - left: 50%; - top: 20px; - transform: translateX(-50%); - text-align: center; - padding: 10px; - background-color: #fff; - border: 1px solid #555; -} - -@media (prefers-color-scheme: dark) { - canvas.three-dimension-canvas.fixed { - border: 1px solid #999; - } - - div.three-dimension-alert { - background-color: #222; - border: 1px solid #999; - } -} - -div.video-container { - position: absolute; - top: 30px; - height: calc(100% - 30px - var(--bottom-margin)); - left: 0px; - width: 100%; - overflow: hidden; -} - -div.video-container img { - width: 100%; - height: 100%; - object-fit: contain; -} - -td.video-source > button { - position: absolute; - height: 70%; - width: calc(80% / 3); - top: 15%; - overflow: hidden; -} - -td.video-source > button > img { - max-height: 50%; - max-width: 50%; - transform: translate(-50%, -50%) scale(120%); - filter: brightness(0%) saturate(0%) invert(100%); -} - -td.video-source > button > svg > path { - fill: none; - stroke-width: 0px; - transition: stroke-width 0.3s; -} - -td.video-source > button.animating > svg > path { - stroke-width: 8px; -} - -td.video-source > button:nth-child(2) > svg > path { - stroke: #ffb8b8; -} - -td.video-source > button:nth-child(3) > svg > path { - stroke: #9aacff; -} - -td.video-source > button:nth-child(1) { - left: 5%; - background-color: #666666; -} - -td.video-source > button:nth-child(2) { - left: calc(5% + 80% / 3 + 5%); - background-color: #ff0000; -} - -td.video-source > button:nth-child(3) { - left: calc(5% + 80% / 3 + 5% + 80% / 3 + 5%); - background-color: #4556a5; -} - -td.video-source > button:nth-child(1):hover { - background-color: #565656; -} - -td.video-source > button:nth-child(2):hover { - background-color: #e00000; -} - -td.video-source > button:nth-child(3):hover { - background-color: #354695; -} - -td.video-source > button:nth-child(1):active { - background-color: #464646; -} - -td.video-source > button:nth-child(2):active { - background-color: #c00000; -} - -td.video-source > button:nth-child(3):active { - background-color: #253685; -} - -div.video-timeline-container { - position: absolute; - right: 5px; - left: 40px; - top: 5px; - height: 30px; -} - -div.video-timeline-container div.timeline-viz-timeline-marker-container { - background-color: #888; -} - -div.video-timeline-container div.timeline-viz-timeline-marker-container div { - background-color: #00f; -} - -td.video-controls { - text-align: center; -} - -table.joysticks-config td { - text-align: center; -} - -table.joysticks-config input[type="number"] { - width: 40px; -} - -div.swerve-canvas-container { - position: absolute; - top: 30px; - height: calc(100% - 30px - var(--bottom-margin)); - left: 0px; - width: 100%; - overflow: hidden; -} - -canvas.swerve-canvas { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - - background-color: #f4f4f4; - border: 1px solid #555; -} - -@media (prefers-color-scheme: dark) { - canvas.swerve-canvas { - background-color: #292929; - border: 1px solid #999; - } -} - -div.mechanism-svg-container { - position: absolute; - top: 30px; - height: calc(100% - 30px - var(--bottom-margin)); - left: 0px; - width: 100%; -} - -svg.mechanism-svg { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); -} - -div.points-background-container { - position: absolute; - top: 30px; - height: calc(100% - 30px - var(--bottom-margin)); - left: 0px; - width: 100%; - overflow: hidden; -} - -div.points-background { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - - background-color: #f4f4f4; - border: 1px solid #555; -} - -@media (prefers-color-scheme: dark) { - div.points-background { - background-color: #292929; - border: 1px solid #999; - } -} - -div.points-background svg { - position: absolute; - stroke-width: 5px; -} - -/* Metadata */ - -div.metadata-table-container { - height: 100%; - width: 100%; - overflow: auto; -} - -table.metadata-table { - width: 100%; - table-layout: fixed; - text-align: left; - word-wrap: break-word; - border-collapse: separate; - border-spacing: 0; -} - -table.metadata-table th:first-child { - width: 150px; -} - -table.metadata-table th { - padding: 6px; - position: sticky; - top: 0px; - border-bottom: 1px solid #222; - background-color: #fff; -} - -table.metadata-table td:first-child { - padding: 6px; - text-align: right; - font-weight: bold; - border-right: 1px solid #222; -} - -table.metadata-table td:not(:first-child) { - padding: 6px; - font-family: monospace; - font-size: 14px; - user-select: text; -} - -table.metadata-table td.no-data { - font-style: italic; -} - -@media (prefers-color-scheme: dark) { - table.metadata-table th { - border-bottom: 1px solid #eee; - background-color: #222; - } - - table.metadata-table td:first-child { - border-right: 1px solid #eee; - } } diff --git a/www/hub.html b/www/hub.html index 270852a7..bc5b61ec 100644 --- a/www/hub.html +++ b/www/hub.html @@ -98,681 +98,31 @@
- diff --git a/www/symbols/sourceList/README.md b/www/symbols/sourceList/README.md new file mode 100644 index 00000000..5257e21e --- /dev/null +++ b/www/symbols/sourceList/README.md @@ -0,0 +1,9 @@ +Assets in this folder are used as source list icons, and adjusted as described to allow dynamic fill colors. + +Replace all instances of: + +fill="#000000" fill-opacity="0.85" + +with: + +fill="currentColor" fill-opacity="1" diff --git a/www/symbols/sourceList/arrow.counterclockwise.circle.fill.svg b/www/symbols/sourceList/arrow.counterclockwise.circle.fill.svg new file mode 100644 index 00000000..36fc7a3c --- /dev/null +++ b/www/symbols/sourceList/arrow.counterclockwise.circle.fill.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/www/symbols/sourceList/arrow.up.and.line.horizontal.and.arrow.down.svg b/www/symbols/sourceList/arrow.up.and.line.horizontal.and.arrow.down.svg new file mode 100644 index 00000000..191f6843 --- /dev/null +++ b/www/symbols/sourceList/arrow.up.and.line.horizontal.and.arrow.down.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/www/symbols/sourceList/arrow.up.circle.svg b/www/symbols/sourceList/arrow.up.circle.svg new file mode 100644 index 00000000..dc91cc98 --- /dev/null +++ b/www/symbols/sourceList/arrow.up.circle.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/www/symbols/sourceList/arrow.up.left.and.down.right.and.arrow.up.right.and.down.left.svg b/www/symbols/sourceList/arrow.up.left.and.down.right.and.arrow.up.right.and.down.left.svg new file mode 100644 index 00000000..229ff61d --- /dev/null +++ b/www/symbols/sourceList/arrow.up.left.and.down.right.and.arrow.up.right.and.down.left.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/www/symbols/sourceList/camera.fill.svg b/www/symbols/sourceList/camera.fill.svg new file mode 100644 index 00000000..4a99304b --- /dev/null +++ b/www/symbols/sourceList/camera.fill.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/www/symbols/sourceList/circle.circle.fill.svg b/www/symbols/sourceList/circle.circle.fill.svg new file mode 100644 index 00000000..a9501ecc --- /dev/null +++ b/www/symbols/sourceList/circle.circle.fill.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/www/symbols/sourceList/circle.dotted.circle.fill.svg b/www/symbols/sourceList/circle.dotted.circle.fill.svg new file mode 100644 index 00000000..412886d3 --- /dev/null +++ b/www/symbols/sourceList/circle.dotted.circle.fill.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/www/symbols/sourceList/circle.fill.svg b/www/symbols/sourceList/circle.fill.svg new file mode 100644 index 00000000..3b5e4369 --- /dev/null +++ b/www/symbols/sourceList/circle.fill.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/www/symbols/sourceList/cone.fill.svg b/www/symbols/sourceList/cone.fill.svg new file mode 100644 index 00000000..2dc0f4f0 --- /dev/null +++ b/www/symbols/sourceList/cone.fill.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/www/symbols/sourceList/gearshape.2.fill.svg b/www/symbols/sourceList/gearshape.2.fill.svg new file mode 100644 index 00000000..a5de6046 --- /dev/null +++ b/www/symbols/sourceList/gearshape.2.fill.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/www/symbols/sourceList/line.3.horizontal.svg b/www/symbols/sourceList/line.3.horizontal.svg new file mode 100644 index 00000000..905087b0 --- /dev/null +++ b/www/symbols/sourceList/line.3.horizontal.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/www/symbols/sourceList/location.fill.svg b/www/symbols/sourceList/location.fill.svg new file mode 100644 index 00000000..290d9f89 --- /dev/null +++ b/www/symbols/sourceList/location.fill.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/www/symbols/sourceList/location.fill.viewfinder.svg b/www/symbols/sourceList/location.fill.viewfinder.svg new file mode 100644 index 00000000..b3683de0 --- /dev/null +++ b/www/symbols/sourceList/location.fill.viewfinder.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/www/symbols/sourceList/map.fill.svg b/www/symbols/sourceList/map.fill.svg new file mode 100644 index 00000000..3eeb0a63 --- /dev/null +++ b/www/symbols/sourceList/map.fill.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/www/symbols/sourceList/mappin.circle.fill.svg b/www/symbols/sourceList/mappin.circle.fill.svg new file mode 100644 index 00000000..ab7def5e --- /dev/null +++ b/www/symbols/sourceList/mappin.circle.fill.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/www/symbols/sourceList/move.3d.svg b/www/symbols/sourceList/move.3d.svg new file mode 100644 index 00000000..2abb67b6 --- /dev/null +++ b/www/symbols/sourceList/move.3d.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/www/symbols/sourceList/point.bottomleft.forward.to.point.topright.scurvepath.fill.svg b/www/symbols/sourceList/point.bottomleft.forward.to.point.topright.scurvepath.fill.svg new file mode 100644 index 00000000..b30d2aa4 --- /dev/null +++ b/www/symbols/sourceList/point.bottomleft.forward.to.point.topright.scurvepath.fill.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/www/symbols/sourceList/puzzlepiece.extension.fill.svg b/www/symbols/sourceList/puzzlepiece.extension.fill.svg new file mode 100644 index 00000000..430aa65a --- /dev/null +++ b/www/symbols/sourceList/puzzlepiece.extension.fill.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/www/symbols/sourceList/qrcode.svg b/www/symbols/sourceList/qrcode.svg new file mode 100644 index 00000000..c717b0c2 --- /dev/null +++ b/www/symbols/sourceList/qrcode.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/www/symbols/sourceList/scope.svg b/www/symbols/sourceList/scope.svg new file mode 100644 index 00000000..b537ed15 --- /dev/null +++ b/www/symbols/sourceList/scope.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/www/symbols/sourceList/star.fill.svg b/www/symbols/sourceList/star.fill.svg new file mode 100644 index 00000000..c7df18ed --- /dev/null +++ b/www/symbols/sourceList/star.fill.svg @@ -0,0 +1,11 @@ + + + + + + + + + From 9d356f80dba20e7b2da105803f2a7728fd7ca76d Mon Sep 17 00:00:00 2001 From: Jonah <47046556+jwbonner@users.noreply.github.com> Date: Sat, 10 Aug 2024 15:54:25 -0400 Subject: [PATCH 002/164] Source list improvements --- src/hub/SourceList.ts | 139 ++++++++---- src/hub/Tabs.ts | 2 +- src/main/main.ts | 30 ++- src/shared/Colors.ts | 29 +++ src/shared/SourceListConfig.ts | 214 +++++++++--------- www/symbols/sourceList/scribble.variable.svg | 11 + .../sourceList/smallcircle.filled.circle.svg | 12 + www/symbols/sourceList/stairs.svg | 11 + 8 files changed, 286 insertions(+), 162 deletions(-) create mode 100644 www/symbols/sourceList/scribble.variable.svg create mode 100644 www/symbols/sourceList/smallcircle.filled.circle.svg create mode 100644 www/symbols/sourceList/stairs.svg diff --git a/src/hub/SourceList.ts b/src/hub/SourceList.ts index 34928db5..a715c6a1 100644 --- a/src/hub/SourceList.ts +++ b/src/hub/SourceList.ts @@ -1,5 +1,11 @@ import { hex, hsl } from "color-convert"; -import { SourceListConfig, SourceListItemState, SourceListState } from "../shared/SourceListConfig"; +import { + SourceListConfig, + SourceListItemState, + SourceListOptionValueConfig, + SourceListState, + SourceListTypeConfig +} from "../shared/SourceListConfig"; import LoggableType from "../shared/log/LoggableType"; import { createUUID } from "../shared/util"; @@ -15,8 +21,8 @@ export default class SourceList { private stopped = false; private config: SourceListConfig; private state: SourceListState = []; - private allAllowedTypes: Set = new Set(); - private parentTypes: Set = new Set(); + private independentAllowedTypes: Set = new Set(); // Types that are not only children + private parentKeys: Map = new Map(); // Map type key to parent key constructor(root: HTMLElement, config: SourceListConfig) { this.config = config; @@ -39,11 +45,13 @@ export default class SourceList { // Summarize config this.config.types.forEach((typeConfig) => { - typeConfig.sourceTypes.forEach((source) => { - this.allAllowedTypes.add(source); - }); - if (typeConfig.parentType !== undefined) { - this.parentTypes.add(typeConfig.parentType); + if (typeConfig.childOf === undefined) { + typeConfig.sourceTypes.forEach((source) => { + this.independentAllowedTypes.add(source); + }); + } + if (typeConfig.parentKey !== undefined) { + this.parentKeys.set(typeConfig.key, typeConfig.parentKey); } }); @@ -110,7 +118,7 @@ export default class SourceList { for (let i = 0; i < this.LIST.childElementCount; i++) { let itemRect = this.LIST.children[i].getBoundingClientRect(); let withinItem = x > itemRect.left && x < itemRect.right && y > itemRect.top && y < itemRect.bottom; - if (withinItem && this.parentTypes.has(this.state[i].type)) { + if (withinItem && this.parentKeys.has(this.state[i].type)) { parentIndex = i; } } @@ -127,19 +135,20 @@ export default class SourceList { ); }); }; - let typeValidList = isTypeValid(this.allAllowedTypes); + let typeValidList = isTypeValid(this.independentAllowedTypes); let typeValidParent = false; if (parentIndex !== null) { + let parentKey = this.parentKeys.get(this.state[parentIndex!].type); let childAllowedTypes: Set = new Set(); this.config.types.forEach((typeConfig) => { - if (typeConfig.parentType === this.state[parentIndex!].type) { + if (typeConfig.childOf === parentKey) { typeConfig.sourceTypes.forEach((type) => childAllowedTypes.add(type)); } }); typeValidParent = isTypeValid(childAllowedTypes); } - // Update highlight + // Add fields and update highlight if (end) { this.DRAG_HIGHLIGHT.hidden = true; let addChild = typeValidParent && parentIndex !== null; @@ -149,9 +158,12 @@ export default class SourceList { let logType = window.log.getType(field); let logTypeString = logType === null ? null : LoggableType[logType]; let structuredType = window.log.getStructuredType(field); + + // Get all possible types + let possibleTypes: { typeConfig: SourceListTypeConfig; logType: string; uses: number }[] = []; for (let i = 0; i < this.config.types.length; i++) { let typeConfig = this.config.types[i]; - if (addChild && typeConfig.parentType !== this.state[parentIndex!].type) { + if (addChild && typeConfig.childOf !== this.parentKeys.get(this.state[parentIndex!].type)) { // Not a child of this parent continue; } @@ -162,28 +174,59 @@ export default class SourceList { finalType = logTypeString; } if (finalType.length > 0) { - let options: { [key: string]: string } = {}; - typeConfig.options.forEach((optionConfig) => { - options[optionConfig.key] = optionConfig.values[0].key; - }); - let state: SourceListItemState = { - type: typeConfig.key, - logKey: field, + possibleTypes.push({ + typeConfig: typeConfig, logType: finalType, - visible: true, - options: options - }; - if (addChild) { - let insertIndex = parentIndex! + 1; - while (insertIndex < this.state.length && this.isChild(insertIndex)) { - insertIndex++; + uses: this.state.filter((itemState) => itemState.type === typeConfig.key).length + }); + } + } + + // Find best type + if (possibleTypes.length === 0) return; + if (this.config.autoAdvance === true) { + possibleTypes.sort((a, b) => a.uses - b.uses); + } + let bestType = possibleTypes[0]; + + // Add to list + let options: { [key: string]: string } = {}; + bestType.typeConfig.options.forEach((optionConfig) => { + if (this.config.autoAdvance !== optionConfig.key) { + // Select first value + options[optionConfig.key] = optionConfig.values[0].key; + } else { + // Select least used value + let useCounts: { valueConfig: SourceListOptionValueConfig; uses: number }[] = optionConfig.values.map( + (valueConfig) => { + return { + valueConfig: valueConfig, + uses: this.state.filter( + (itemState) => + optionConfig.key in itemState.options && itemState.options[optionConfig.key] === valueConfig.key + ).length + }; } - this.addItem(state, insertIndex); - } else { - this.addItem(state); - } - break; + ); + useCounts.sort((a, b) => a.uses - b.uses); + options[optionConfig.key] = useCounts[0].valueConfig.key; + } + }); + let state: SourceListItemState = { + type: bestType.typeConfig.key, + logKey: field, + logType: bestType.logType, + visible: true, + options: options + }; + if (addChild) { + let insertIndex = parentIndex! + 1; + while (insertIndex < this.state.length && this.isChild(insertIndex)) { + insertIndex++; } + this.addItem(state, insertIndex); + } else { + this.addItem(state); } }); } @@ -235,7 +278,7 @@ export default class SourceList { // Check if child type let typeConfig = this.config.types.find((typeConfig) => typeConfig.key === state.type); - let isChild = typeConfig !== undefined && typeConfig.parentType !== undefined; + let isChild = typeConfig !== undefined && typeConfig.childOf !== undefined; // Type controls let typeButton = item.getElementsByClassName("type")[0] as HTMLButtonElement; @@ -255,18 +298,22 @@ export default class SourceList { this.state[index] = newState; this.updateItem(item, newState); - if (newState.type !== originalType && !isChild) { - // Changed parent type, remove all children - index++; - if (!this.isChild(index)) return; - let removeCount = 0; - while (index + removeCount < this.state.length) { - removeCount++; - if (!this.isChild(index + removeCount)) break; - } - this.state.splice(index, removeCount); - for (let i = 0; i < removeCount; i++) { - this.LIST.removeChild(this.LIST.children[index]); + if (!isChild) { + let originalParentKey = this.config.types.find((typeConfig) => typeConfig.key === originalType)?.parentKey; + let newParentKey = this.config.types.find((typeConfig) => typeConfig.key === newState.type)?.parentKey; + if (originalParentKey !== newParentKey) { + // Changed parent key, remove children + index++; + if (!this.isChild(index)) return; + let childCount = 0; + while (index + childCount < this.state.length) { + childCount++; + if (!this.isChild(index + childCount)) break; + } + this.state.splice(index, childCount); + for (let i = 0; i < childCount; i++) { + this.LIST.removeChild(this.LIST.children[index]); + } } } }; @@ -406,7 +453,7 @@ export default class SourceList { private isChild(index: number) { if (index < 0 || index >= this.state.length) return false; let typeConfig = this.config.types.find((typeConfig) => typeConfig.key === this.state[index].type); - return typeConfig !== undefined && typeConfig.parentType !== undefined; + return typeConfig !== undefined && typeConfig.childOf !== undefined; } /** diff --git a/src/hub/Tabs.ts b/src/hub/Tabs.ts index acd8006f..126b6565 100644 --- a/src/hub/Tabs.ts +++ b/src/hub/Tabs.ts @@ -3,7 +3,7 @@ import TabType, { getDefaultTabTitle, getTabIcon, TIMELINE_VIZ_TYPES } from "../ import { UnitConversionPreset } from "../shared/units"; import ScrollSensor from "./ScrollSensor"; import SourceList from "./SourceList"; -import { OdometryConfig } from "../shared/SourceListConfig"; +import { LineGraphConfig, OdometryConfig } from "../shared/SourceListConfig"; import TabController from "./TabController"; import ConsoleController from "./tabControllers/ConsoleController"; import DocumentationController from "./tabControllers/DocumentationController"; diff --git a/src/main/main.ts b/src/main/main.ts index 54e832ec..c2c44333 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -512,7 +512,7 @@ function handleHubMessage(window: BrowserWindow, message: NamedMessage) { ); } config.types.forEach((typeConfig) => { - if (typeConfig.sourceTypes.includes(state.logType) && typeConfig.parentType === currentTypeConfig.parentType) { + if (typeConfig.sourceTypes.includes(state.logType) && typeConfig.childOf === currentTypeConfig.childOf) { let current = state.type === typeConfig.key; let optionConfig = current ? undefined @@ -530,10 +530,20 @@ function handleHubMessage(window: BrowserWindow, message: NamedMessage) { label: optionValue.display, click() { state.type = typeConfig.key; - state.options = {}; + let newOptions: { [key: string]: string } = {}; typeConfig.options.forEach((optionConfig) => { - state.options[optionConfig.key] = optionConfig.values[0].key; + if ( + optionConfig.key in state.options && + optionConfig.values + .map((valueConfig) => valueConfig.key) + .includes(state.options[optionConfig.key]) + ) { + newOptions[optionConfig.key] = state.options[optionConfig.key]; + } else { + newOptions[optionConfig.key] = optionConfig.values[0].key; + } }); + state.options = newOptions; state.options[typeConfig.initialSelectionOption!] = optionValue.key; respond(); } @@ -544,10 +554,20 @@ function handleHubMessage(window: BrowserWindow, message: NamedMessage) { ? undefined : () => { state.type = typeConfig.key; - state.options = {}; + let newOptions: { [key: string]: string } = {}; typeConfig.options.forEach((optionConfig) => { - state.options[optionConfig.key] = optionConfig.values[0].key; + if ( + optionConfig.key in state.options && + optionConfig.values + .map((valueConfig) => valueConfig.key) + .includes(state.options[optionConfig.key]) + ) { + newOptions[optionConfig.key] = state.options[optionConfig.key]; + } else { + newOptions[optionConfig.key] = optionConfig.values[0].key; + } }); + state.options = newOptions; respond(); } }) diff --git a/src/shared/Colors.ts b/src/shared/Colors.ts index cffdedd2..de8ac17a 100644 --- a/src/shared/Colors.ts +++ b/src/shared/Colors.ts @@ -1,3 +1,6 @@ +import { SourceListOptionValueConfig } from "./SourceListConfig"; + +// TODO: Remove export const AllColors = [ "#2b66a2", "#e5b31b", @@ -20,4 +23,30 @@ export const AllColors = [ "#2e3b28" ]; +// TODO: Remove export const SimpleColors = ["#61a5f2", "#ebc542", "#3b875a"]; + +export const GraphColors: SourceListOptionValueConfig[] = [ + { key: "#2b66a2", display: "Blue" }, + { key: "#e5b31b", display: "Gold" }, + { key: "#af2437", display: "Red" }, + { key: "#80588e", display: "Purple" }, + { key: "#e48b32", display: "Orange" }, + { key: "#c0b487", display: "Tan" }, + { key: "#858584", display: "Gray" }, + { key: "#3b875a", display: "Green" }, + { key: "#d993aa", display: "Pink" }, + { key: "#eb987e", display: "Peach" }, + { key: "#5f4528", display: "Brown" } +]; + +export const NeonColors: SourceListOptionValueConfig[] = [ + { key: "#ff0000", display: "Red" }, + { key: "#ffa600", display: "Orange" }, + { key: "#ffff00", display: "Yellow" }, + { key: "#00ff00", display: "Green" }, + { key: "#0000ff", display: "Blue" }, + { key: "#00ffff", display: "Cyan" }, + { key: "#a600ff", display: "Purple" }, + { key: "#ff00ff", display: "Magenta" } +]; diff --git a/src/shared/SourceListConfig.ts b/src/shared/SourceListConfig.ts index 0cd00176..f3a88cef 100644 --- a/src/shared/SourceListConfig.ts +++ b/src/shared/SourceListConfig.ts @@ -1,5 +1,8 @@ +import { GraphColors, NeonColors } from "./Colors"; + export type SourceListConfig = { title: string; + autoAdvance: boolean | string; // True advances type, string advances option types: SourceListTypeConfig[]; }; @@ -11,7 +14,8 @@ export type SourceListTypeConfig = { color: string; // Option key or hex (starting with #) darkColor?: string; sourceTypes: string[]; - parentType?: string; + parentKey?: string; // Identifies parents with shared children types + childOf?: string; // Parent key this child is attached to // If only one option, show without submenu options: SourceListOptionConfig[]; @@ -22,10 +26,12 @@ export type SourceListOptionConfig = { key: string; display: string; showInTypeName: boolean; - values: { - key: string; - display: string; - }[]; + values: SourceListOptionValueConfig[]; +}; + +export type SourceListOptionValueConfig = { + key: string; + display: string; }; export type SourceListState = SourceListItemState[]; @@ -40,6 +46,7 @@ export type SourceListItemState = { export const OdometryConfig: SourceListConfig = { title: "Poses", + autoAdvance: true, types: [ { key: "robot", @@ -60,7 +67,8 @@ export const OdometryConfig: SourceListConfig = { ] } ], - initialSelectionOption: "model" + initialSelectionOption: "model", + parentKey: "robot" }, { key: "ghost", @@ -83,17 +91,11 @@ export const OdometryConfig: SourceListConfig = { key: "color", display: "Color", showInTypeName: false, - values: [ - { key: "#ff0000", display: "Red" }, - { key: "#00ff00", display: "Green" }, - { key: "#0000ff", display: "Blue" }, - { key: "#ffff00", display: "Yellow" }, - { key: "#ff00ff", display: "Magenta" }, - { key: "#00ffff", display: "Cyan" } - ] + values: NeonColors } ], - initialSelectionOption: "model" + initialSelectionOption: "model", + parentKey: "robot" }, { key: "component", @@ -101,9 +103,9 @@ export const OdometryConfig: SourceListConfig = { symbol: "puzzlepiece.extension.fill", showInTypeName: true, color: "#888888", - sourceTypes: ["NumberArray", "Pose2d", "Pose2d[]", "Transform2d", "Transform2d[]"], + sourceTypes: ["NumberArray", "Pose3d", "Pose3d[]", "Transform3d", "Transform3d[]"], options: [], - parentType: "robot" + childOf: "robot" }, { key: "camera", @@ -111,9 +113,9 @@ export const OdometryConfig: SourceListConfig = { symbol: "camera.fill", showInTypeName: true, color: "#888888", - sourceTypes: ["NumberArray", "Pose2d", "Transform2d"], + sourceTypes: ["NumberArray", "Pose3d", "Transform3d"], options: [], - parentType: "robot" + childOf: "robot" }, { key: "vision", @@ -202,94 +204,86 @@ export const OdometryConfig: SourceListConfig = { ] }; -// const LineGraphConfig: SourceListConfig = { -// name: "Left Axis", -// types: [ -// { -// key: "stepped", -// display: "Stepped", -// symbol: "circle.fill", -// showInTypeName: false, -// color: "color", -// options: [ -// { -// key: "color", -// display: "Color", -// showInTypeName: false, -// values: [ -// { key: "#ff0000", display: "Red" }, -// { key: "#00ff00", display: "Green" }, -// { key: "#0000ff", display: "Blue" } -// ] -// }, -// { -// key: "thickness", -// display: "Thickness", -// showInTypeName: false, -// values: [ -// { key: "normal", display: "Normal" }, -// { key: "bold", display: "Bold" }, -// { key: "verybold", display: "Very Bold" } -// ] -// } -// ] -// }, -// { -// key: "smooth", -// display: "Smooth", -// symbol: "circle.circle.fill", -// showInTypeName: false, -// color: "color", -// options: [ -// { -// key: "color", -// display: "Color", -// showInTypeName: false, -// values: [ -// { key: "#ff0000", display: "Red" }, -// { key: "#00ff00", display: "Green" }, -// { key: "#0000ff", display: "Blue" } -// ] -// }, -// { -// key: "thickness", -// display: "Thickness", -// showInTypeName: false, -// values: [ -// { key: "normal", display: "Normal" }, -// { key: "bold", display: "Bold" }, -// { key: "verybold", display: "Very Bold" } -// ] -// } -// ] -// }, -// { -// key: "points", -// display: "Points", -// symbol: "circle.dotted.circle.fill", -// showInTypeName: false, -// color: "color", -// options: [ -// { -// key: "color", -// display: "Color", -// showInTypeName: false, -// values: [ -// { key: "#ff0000", display: "Red" }, -// { key: "#00ff00", display: "Green" }, -// { key: "#0000ff", display: "Blue" } -// ] -// }, -// { -// key: "size", -// display: "Size", -// showInTypeName: false, -// values: [ -// { key: "normal", display: "Normal" }, -// { key: "large", display: "Large" } -// ] -// } -// ] -// } -// ] -// }; +export const LineGraphConfig: SourceListConfig = { + title: "Left Axis", + autoAdvance: "color", + types: [ + { + key: "smooth", + display: "Smooth", + symbol: "scribble.variable", + showInTypeName: false, + color: "color", + sourceTypes: ["Number"], + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: GraphColors + }, + { + key: "thickness", + display: "Thickness", + showInTypeName: false, + values: [ + { key: "normal", display: "Normal" }, + { key: "bold", display: "Bold" }, + { key: "verybold", display: "Very Bold" } + ] + } + ] + }, + { + key: "stepped", + display: "Stepped", + symbol: "stairs", + showInTypeName: false, + color: "color", + sourceTypes: ["Number"], + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: GraphColors + }, + { + key: "thickness", + display: "Thickness", + showInTypeName: false, + values: [ + { key: "normal", display: "Normal" }, + { key: "bold", display: "Bold" }, + { key: "verybold", display: "Very Bold" } + ] + } + ] + }, + { + key: "points", + display: "Points", + symbol: "smallcircle.filled.circle", + showInTypeName: false, + color: "color", + sourceTypes: ["Number"], + options: [ + { + key: "color", + display: "Color", + showInTypeName: false, + values: GraphColors + }, + { + key: "size", + display: "Size", + showInTypeName: false, + values: [ + { key: "normal", display: "Normal" }, + { key: "large", display: "Large" } + ] + } + ] + } + ] +}; diff --git a/www/symbols/sourceList/scribble.variable.svg b/www/symbols/sourceList/scribble.variable.svg new file mode 100644 index 00000000..b1b812fe --- /dev/null +++ b/www/symbols/sourceList/scribble.variable.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/www/symbols/sourceList/smallcircle.filled.circle.svg b/www/symbols/sourceList/smallcircle.filled.circle.svg new file mode 100644 index 00000000..097f6d76 --- /dev/null +++ b/www/symbols/sourceList/smallcircle.filled.circle.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/www/symbols/sourceList/stairs.svg b/www/symbols/sourceList/stairs.svg new file mode 100644 index 00000000..eb5f3680 --- /dev/null +++ b/www/symbols/sourceList/stairs.svg @@ -0,0 +1,11 @@ + + + + + + + + + From 820c8eb12d425036371fa5c0e5cfefd30800a5e5 Mon Sep 17 00:00:00 2001 From: Jonah <47046556+jwbonner@users.noreply.github.com> Date: Sat, 10 Aug 2024 15:59:58 -0400 Subject: [PATCH 003/164] Add panning to scroll sensor --- src/hub/ScrollSensor.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/hub/ScrollSensor.ts b/src/hub/ScrollSensor.ts index 8a8e8e5e..fd271a80 100644 --- a/src/hub/ScrollSensor.ts +++ b/src/hub/ScrollSensor.ts @@ -10,6 +10,10 @@ export default class ScrollSensor { private lastScrollLeft: number = 0; private lastScrollTop: number = 0; + private panActive = false; + private panStartCursorX = 0; + private panLastCursorX = 0; + /** * Creates a new ScrollSensor. * @param container The container element. The overflow should be "scroll" and the scrollbar should be hidden. The child element should have the dimensions 1000000x1000000px. @@ -19,10 +23,32 @@ export default class ScrollSensor { this.container = container; this.callback = callback; + // Scroll events this.resetNext = true; this.container.addEventListener("scroll", () => { this.update(); }); + + // Pan events + container.addEventListener("mousedown", (event) => { + this.panActive = true; + let x = event.clientX - container.getBoundingClientRect().x; + this.panStartCursorX = x; + this.panLastCursorX = x; + }); + container.addEventListener("mouseleave", () => { + this.panActive = false; + }); + container.addEventListener("mouseup", () => { + this.panActive = false; + }); + container.addEventListener("mousemove", (event) => { + if (this.panActive) { + let cursorX = event.clientX - container.getBoundingClientRect().x; + callback(this.panLastCursorX - cursorX, 0); + this.panLastCursorX = cursorX; + } + }); } /** Should be called periodically to trigger resets. */ From a4edbc2d8bdebb85ac11d31b61881b6fd2b9ae96 Mon Sep 17 00:00:00 2001 From: Jonah <47046556+jwbonner@users.noreply.github.com> Date: Sat, 10 Aug 2024 19:08:37 -0400 Subject: [PATCH 004/164] Add timeline --- src/hub/ScrollSensor.ts | 2 - src/hub/Selection.ts | 76 +++++++ src/hub/Tabs.ts | 17 +- src/hub/Timeline.ts | 196 ++++++++++++++++++ src/hub/tabControllers/LineGraphController.ts | 2 +- src/shared/log/LogUtil.ts | 81 ++++++++ www/hub.css | 50 ++++- www/hub.html | 9 +- 8 files changed, 420 insertions(+), 13 deletions(-) create mode 100644 src/hub/Timeline.ts diff --git a/src/hub/ScrollSensor.ts b/src/hub/ScrollSensor.ts index fd271a80..52507b0e 100644 --- a/src/hub/ScrollSensor.ts +++ b/src/hub/ScrollSensor.ts @@ -11,7 +11,6 @@ export default class ScrollSensor { private lastScrollTop: number = 0; private panActive = false; - private panStartCursorX = 0; private panLastCursorX = 0; /** @@ -33,7 +32,6 @@ export default class ScrollSensor { container.addEventListener("mousedown", (event) => { this.panActive = true; let x = event.clientX - container.getBoundingClientRect().x; - this.panStartCursorX = x; this.panLastCursorX = x; }); container.addEventListener("mouseleave", () => { diff --git a/src/hub/Selection.ts b/src/hub/Selection.ts index 5cbf4ae4..e45cf409 100644 --- a/src/hub/Selection.ts +++ b/src/hub/Selection.ts @@ -2,6 +2,8 @@ import { AKIT_TIMESTAMP_KEYS } from "../shared/log/LogUtil"; export default class Selection { private STEP_SIZE = 0.02; // When using left-right arrows keys on non-AdvantageKit logs + private TIMELINE_MIN_ZOOM_TIME = 0.05; + private TIMELINE_ZOOM_BASE = 1.001; private PLAY_BUTTON = document.getElementsByClassName("play")[0] as HTMLElement; private PAUSE_BUTTON = document.getElementsByClassName("pause")[0] as HTMLElement; @@ -11,6 +13,8 @@ export default class Selection { private mode: SelectionMode = SelectionMode.Idle; private hoveredTime: number | null = null; private staticTime: number = 0; + private timelineRange: [number, number] = [0, 10]; + private timelineMaxZoom = true; // When at maximum zoom, maintain it as the available range increases private playbackStartLog: number = 0; private playbackStartReal: number = 0; private playbackSpeed: number = 1; @@ -265,6 +269,78 @@ export default class Selection { } this.playbackSpeed = speed; } + + /** Returns the visible range for the timeline. */ + getTimelineRange(): [number, number] { + this.applyTimelineScroll(0, 0, 0); + return this.timelineRange; + } + + /** Updates the timeline range based on a scroll event. */ + applyTimelineScroll(dx: number, dy: number, widthPixels: number) { + // Find available timestamp range + let availableRange = window.log.getTimestampRange(); + availableRange = [availableRange[0], availableRange[1]]; + let liveTime = this.getCurrentLiveTime(); + if (liveTime !== null) { + availableRange[1] = liveTime; + } + if (availableRange[1] - availableRange[0] < this.TIMELINE_MIN_ZOOM_TIME) { + availableRange[1] = availableRange[0] + this.TIMELINE_ZOOM_BASE; + } + + // Apply horizontal scroll + if (this.mode === SelectionMode.Locked) { + let zoom = this.timelineRange[1] - this.timelineRange[0]; + this.timelineRange[0] = availableRange[1] - zoom; + this.timelineRange[1] = availableRange[1]; + if (dx < 0) this.unlock(); // Unlock if attempting to scroll away + } else if (dx !== 0) { + let secsPerPixel = (this.timelineRange[1] - this.timelineRange[0]) / widthPixels; + this.timelineRange[0] += dx * secsPerPixel; + this.timelineRange[1] += dx * secsPerPixel; + } + + // Apply vertical scroll + if (dy !== 0 && (!this.timelineMaxZoom || dy < 0)) { + // If max zoom, ignore positive scroll (no effect, just apply the max zoom) + let zoomPercent = Math.pow(this.TIMELINE_ZOOM_BASE, dy); + let newZoom = (this.timelineRange[1] - this.timelineRange[0]) * zoomPercent; + if (newZoom < this.TIMELINE_MIN_ZOOM_TIME) newZoom = this.TIMELINE_MIN_ZOOM_TIME; + if (newZoom > availableRange[1] - availableRange[0]) newZoom = availableRange[1] - availableRange[0]; + + let hoveredTime = this.getHoveredTime(); + if (hoveredTime === null) { + hoveredTime = (this.timelineRange[0] + this.timelineRange[1]) / 2; + } + let hoveredPercent = (hoveredTime - this.timelineRange[0]) / (this.timelineRange[1] - this.timelineRange[0]); + this.timelineRange[0] = hoveredTime - newZoom * hoveredPercent; + this.timelineRange[1] = hoveredTime + newZoom * (1 - hoveredPercent); + } else if (this.timelineMaxZoom) { + this.timelineRange = availableRange; + } + + // Enforce max range + if (this.timelineRange[1] - this.timelineRange[0] > availableRange[1] - availableRange[0]) { + this.timelineRange = availableRange; + } + this.timelineMaxZoom = this.timelineRange[1] - this.timelineRange[0] === availableRange[1] - availableRange[0]; + + // Enforce left limit + if (this.timelineRange[0] < availableRange[0]) { + let shift = availableRange[0] - this.timelineRange[0]; + this.timelineRange[0] += shift; + this.timelineRange[1] += shift; + } + + // Enforce right limit + if (this.timelineRange[1] > availableRange[1]) { + let shift = availableRange[1] - this.timelineRange[1]; + this.timelineRange[0] += shift; + this.timelineRange[1] += shift; + if (dx > 0) this.lock(); // Lock if action is intentional + } + } } export enum SelectionMode { diff --git a/src/hub/Tabs.ts b/src/hub/Tabs.ts index 126b6565..0d4471d7 100644 --- a/src/hub/Tabs.ts +++ b/src/hub/Tabs.ts @@ -19,9 +19,11 @@ import TableController from "./tabControllers/TableController"; import ThreeDimensionController from "./tabControllers/ThreeDimensionController"; import TimelineVizController from "./tabControllers/TimelineVizController"; import VideoController from "./tabControllers/VideoController"; +import Timeline from "./Timeline"; export default class Tabs { private VIEWER = document.getElementsByClassName("viewer")[0] as HTMLElement; + private TIMELINE_CONTAINER = document.getElementsByClassName("timeline")[0] as HTMLElement; private TAB_BAR = document.getElementsByClassName("tab-bar")[0]; private SHADOW_LEFT = document.getElementsByClassName("tab-bar-shadow-left")[0] as HTMLElement; private SHADOW_RIGHT = document.getElementsByClassName("tab-bar-shadow-right")[0] as HTMLElement; @@ -40,7 +42,8 @@ export default class Tabs { contentElement: HTMLElement; }[] = []; private selectedTab = 0; - private scrollSensor: ScrollSensor; + private tabsScrollSensor: ScrollSensor; + private timeline: Timeline; constructor() { // Hover and click handling @@ -108,23 +111,23 @@ export default class Tabs { this.addTab(TabType.LineGraph); // Scroll management - this.scrollSensor = new ScrollSensor(this.SCROLL_OVERLAY, (dx: number, dy: number) => { + this.tabsScrollSensor = new ScrollSensor(this.SCROLL_OVERLAY, (dx: number, dy: number) => { this.TAB_BAR.scrollLeft += dx + dy; }); + // Add timeline + this.timeline = new Timeline(this.TIMELINE_CONTAINER); + // Periodic function let periodic = () => { this.SHADOW_LEFT.style.opacity = Math.floor(this.TAB_BAR.scrollLeft) <= 0 ? "0" : "1"; this.SHADOW_RIGHT.style.opacity = Math.ceil(this.TAB_BAR.scrollLeft) >= this.TAB_BAR.scrollWidth - this.TAB_BAR.clientWidth ? "0" : "1"; - this.scrollSensor.periodic(); + this.tabsScrollSensor.periodic(); + this.timeline.periodic(); window.requestAnimationFrame(periodic); }; window.requestAnimationFrame(periodic); - - let sourceListRoot = (document.getElementsByClassName("tab-content")[0] as HTMLElement) - .firstElementChild as HTMLElement; - new SourceList(sourceListRoot, OdometryConfig); } /** Returns the current state. */ diff --git a/src/hub/Timeline.ts b/src/hub/Timeline.ts new file mode 100644 index 00000000..df95abf5 --- /dev/null +++ b/src/hub/Timeline.ts @@ -0,0 +1,196 @@ +import { getRobotStateRanges } from "../shared/log/LogUtil"; +import { clampValue, cleanFloat, scaleValue } from "../shared/util"; +import ScrollSensor from "./ScrollSensor"; + +export default class Timeline { + private STEP_TARGET_PX = 125; + + private CONTAINER: HTMLElement; + private CANVAS: HTMLCanvasElement; + private SCROLL_OVERLAY: HTMLElement; + + private scrollSensor: ScrollSensor; + private mouseDownX = 0; + private lastCursorX: number | null = null; + + constructor(container: HTMLElement) { + this.CONTAINER = container; + this.CANVAS = container.getElementsByClassName("timeline-canvas")[0] as HTMLCanvasElement; + this.SCROLL_OVERLAY = container.getElementsByClassName("timeline-scroll")[0] as HTMLCanvasElement; + + // Hover handling + this.SCROLL_OVERLAY.addEventListener("mousemove", (event) => { + this.lastCursorX = event.clientX - this.SCROLL_OVERLAY.getBoundingClientRect().x; + }); + this.SCROLL_OVERLAY.addEventListener("mouseleave", () => { + this.lastCursorX = null; + window.selection.setHoveredTime(null); + }); + + // Selection handling + this.SCROLL_OVERLAY.addEventListener("mousedown", (event) => { + this.mouseDownX = event.clientX - this.SCROLL_OVERLAY.getBoundingClientRect().x; + }); + this.SCROLL_OVERLAY.addEventListener("click", (event) => { + if (Math.abs(event.clientX - this.SCROLL_OVERLAY.getBoundingClientRect().x - this.mouseDownX) <= 5) { + let hoveredTime = window.selection.getHoveredTime(); + if (hoveredTime) { + window.selection.setSelectedTime(hoveredTime); + } + } + }); + this.SCROLL_OVERLAY.addEventListener("contextmenu", () => { + window.selection.goIdle(); + }); + + // Scroll handling + this.scrollSensor = new ScrollSensor(this.SCROLL_OVERLAY, (dx: number, dy: number) => { + window.selection.applyTimelineScroll(dx, dy, this.SCROLL_OVERLAY.clientWidth); + }); + } + + periodic() { + this.scrollSensor.periodic(); + + // Initial setup and scaling + const devicePixelRatio = window.devicePixelRatio; + let context = this.CANVAS.getContext("2d") as CanvasRenderingContext2D; + let width = this.CONTAINER.clientWidth; + let height = this.CONTAINER.clientHeight; + let light = !window.matchMedia("(prefers-color-scheme: dark)").matches; + let timeRange = window.selection.getTimelineRange(); + this.CANVAS.width = width * devicePixelRatio; + this.CANVAS.height = height * devicePixelRatio; + context.scale(devicePixelRatio, devicePixelRatio); + context.clearRect(0, 0, width, height); + context.font = "200 12px ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont"; + + // Calculate step size + let stepSize: number; + { + let stepCount = width / this.STEP_TARGET_PX; + let stepValueApprox = (timeRange[1] - timeRange[0]) / stepCount; + let roundBase = 10 ** Math.floor(Math.log10(stepValueApprox)); + let multiplierLookup = [0, 1, 2, 2, 5, 5, 5, 5, 5, 10, 10]; // Use friendly numbers if possible + stepSize = roundBase * multiplierLookup[Math.round(stepValueApprox / roundBase)]; + } + + // Draw state ranges + context.lineWidth = 1; + let rangeBorders: number[] = []; + getRobotStateRanges(window.log).forEach((range) => { + if (range.mode === "disabled") return; + let startTime = range.start; + let endTime = range.end === undefined ? timeRange[1] : range.end; + let isAuto = range.mode === "auto"; + + if (isAuto) { + context.fillStyle = light ? "#00cc00" : "#00bb00"; + } else { + context.fillStyle = light ? "#00aaff" : "#0000bb"; + } + let startX = clampValue(scaleValue(startTime, timeRange, [0, width]), 0, width); + let endX = clampValue(scaleValue(endTime, timeRange, [0, width]), 0, width); + context.fillRect(startX, 0, endX - startX, height); + + if (!rangeBorders.includes(range.start)) { + rangeBorders.push(range.start); + } + if (range.end !== undefined && !rangeBorders.includes(range.end)) { + rangeBorders.push(range.end); + } + }); + + // Update hovered time + if (this.lastCursorX !== null && this.lastCursorX > 0 && this.lastCursorX < width) { + let cursorTime = scaleValue(this.lastCursorX, [0, width], timeRange); + let nearestRangeBorder = rangeBorders.reduce((prev, border) => { + if (Math.abs(cursorTime - border) < Math.abs(cursorTime - prev)) { + return border; + } else { + return prev; + } + }, Infinity); + let nearestRangeBorderX = scaleValue(nearestRangeBorder, timeRange, [0, width]); + window.selection.setHoveredTime( + Math.abs(this.lastCursorX - nearestRangeBorderX) < 5 ? nearestRangeBorder : cursorTime + ); + } + + // Draw a vertical marker line at the time + let markedXs: number[] = []; + let markTime = (time: number, alpha: number) => { + if (time >= timeRange[0] && time <= timeRange[1]) { + context.globalAlpha = alpha; + context.lineWidth = 1; + context.strokeStyle = light ? "#222" : "#eee"; + + let x = scaleValue(time, timeRange, [0, width]); + if (x > 1 && x < width - 1) { + let triangleSideLength = 6; + let triangleHeight = 0.5 * Math.sqrt(3) * triangleSideLength; + + markedXs.push(x); + context.beginPath(); + context.moveTo(x, triangleHeight); + for (let i = x - triangleSideLength / 2; i <= x + triangleSideLength / 2; i++) { + context.lineTo(i, -1); + context.moveTo(x, triangleHeight); + } + context.lineTo(x, height - triangleHeight); + for (let i = x - triangleSideLength / 2; i <= x + triangleSideLength / 2; i++) { + context.lineTo(i, height + 1); + context.moveTo(x, height - triangleHeight); + } + context.stroke(); + } + context.globalAlpha = 1; + } + }; + + // Mark hovered and selected times + let hoveredTime = window.selection.getHoveredTime(); + let selectedTime = window.selection.getSelectedTime(); + if (hoveredTime !== null) markTime(hoveredTime, 0.5); + if (selectedTime !== null) markTime(selectedTime, 1); + + // Draw tick marks + context.lineWidth = 0.5; + context.strokeStyle = light ? "#222" : "#eee"; + context.fillStyle = light ? "#222" : "#eee"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.globalAlpha = 0.5; + let stepPos = Math.ceil(cleanFloat(timeRange[0] / stepSize)) * stepSize; + while (true) { + let x = scaleValue(stepPos, timeRange, [0, width]); + if (x > width + 1) { + break; + } + + let text = cleanFloat(stepPos).toString() + "s"; + let textWidth = context.measureText(text).width; + let textX = clampValue(x, textWidth / 2 + 3, width - textWidth / 2 - 3); + let textXRange = [textX - textWidth / 2, textX + textWidth / 2]; + let markDistance = markedXs.reduce((min, x) => { + let dist = 0; + if (x < textXRange[0]) dist = textXRange[0] - x; + if (x > textXRange[1]) dist = x - textXRange[1]; + return dist < min ? dist : min; + }, Infinity); + context.globalAlpha = clampValue(scaleValue(markDistance, [0, 20], [0.2, 0.5]), 0, 1); + context.fillText(text, textX, height / 2); + + context.beginPath(); + context.moveTo(x, 0); + context.lineTo(x, 8); + context.moveTo(x, height - 8); + context.lineTo(x, height); + context.stroke(); + context.globalAlpha = 0.5; + + stepPos += stepSize; + } + context.globalAlpha = 1; + } +} diff --git a/src/hub/tabControllers/LineGraphController.ts b/src/hub/tabControllers/LineGraphController.ts index 6e8fbb37..ab08b152 100644 --- a/src/hub/tabControllers/LineGraphController.ts +++ b/src/hub/tabControllers/LineGraphController.ts @@ -1031,7 +1031,7 @@ export default class LineGraphController implements TabController { }); }); - //Use similar logic as main axes but with an extra decimal point of precision to format the popup timestamps + // Use similar logic as main axes but with an extra decimal point of precision to format the popup timestamps let formatMarkedTimestampText = (time: number): string => { let fractionDigits = Math.max(0, -Math.floor(Math.log10(xAxis.step / 10))); return time.toFixed(fractionDigits) + "s"; diff --git a/src/shared/log/LogUtil.ts b/src/shared/log/LogUtil.ts index ed5d6a59..50763010 100644 --- a/src/shared/log/LogUtil.ts +++ b/src/shared/log/LogUtil.ts @@ -25,6 +25,13 @@ export const ENABLED_KEYS = withMergedKeys([ "/DSLog/Status/DSDisabled", "RobotEnable" // Phoenix ]); +export const AUTONOMOUS_KEYS = withMergedKeys([ + "/DriverStation/Autonomous", + "NT:/AdvantageKit/DriverStation/Autonomous", + "DS:autonomous", + "NT:/FMSInfo/FMSControlData", + "/DSLog/Status/DSTeleop" +]); export const ALLIANCE_KEYS = withMergedKeys([ "/DriverStation/AllianceStation", "NT:/AdvantageKit/DriverStation/AllianceStation", @@ -186,6 +193,80 @@ export function getEnabledData(log: Log): LogValueSetBoolean | null { return enabledData; } +export function getAutonomousData(log: Log): LogValueSetBoolean | null { + let autonomousKey = AUTONOMOUS_KEYS.find((key) => log.getFieldKeys().includes(key)); + if (!autonomousKey) return null; + let autonomousData: LogValueSetBoolean | null = null; + if (autonomousKey.endsWith("FMSControlData")) { + let tempAutoData = log.getNumber(autonomousKey, -Infinity, Infinity); + if (tempAutoData) { + autonomousData = { + timestamps: tempAutoData.timestamps, + values: tempAutoData.values.map((controlWord) => ((controlWord >> 1) & 1) !== 0) + }; + } + } else { + let tempAutoData = log.getBoolean(autonomousKey, -Infinity, Infinity); + if (!tempAutoData) return null; + autonomousData = tempAutoData; + if (autonomousKey.endsWith("DSTeleop")) { + autonomousData = { + timestamps: autonomousData.timestamps, + values: autonomousData.values.map((value) => !value) + }; + } + } + return autonomousData; +} + +export function getRobotStateRanges(log: Log): { start: number; end?: number; mode: "disabled" | "auto" | "teleop" }[] { + let enabledData = getEnabledData(log); + let autoData = getAutonomousData(log); + if (!enabledData || !autoData) return []; + + // Combine enabled and auto data + let allTimestamps = [...enabledData.timestamps, ...autoData.timestamps]; + allTimestamps = [...new Set(allTimestamps)]; + allTimestamps.sort((a, b) => Number(a) - Number(b)); + let combined: { timestamp: number; enabled: boolean; auto: boolean }[] = []; + allTimestamps.forEach((timestamp) => { + let enabled = enabledData!.values.findLast((_, index) => enabledData!.timestamps[index] <= timestamp); + let auto = autoData!.values.findLast((_, index) => autoData!.timestamps[index] <= timestamp); + if (enabled === undefined) enabled = false; + if (auto === undefined) auto = false; + combined.push({ + timestamp: timestamp, + enabled: enabled, + auto: auto + }); + }); + + // Get ranges + let ranges: { start: number; end?: number; mode: "disabled" | "auto" | "teleop" }[] = []; + combined.forEach((sample, index) => { + let end: number | undefined = undefined; + if (sample.enabled) { + if (index < combined.length - 1) { + end = combined[index + 1].timestamp; + } + ranges.push({ + start: sample.timestamp, + end: end, + mode: sample.auto ? "auto" : "teleop" + }); + } else { + let endSample = combined.find((endSample) => endSample.timestamp > sample.timestamp && endSample.enabled); + if (endSample) end = endSample.timestamp; + ranges.push({ + start: sample.timestamp, + end: end, + mode: "disabled" + }); + } + }); + return ranges; +} + export function getIsRedAlliance(log: Log, time: number): boolean { let allianceKey = ALLIANCE_KEYS.find((key) => log.getFieldKeys().includes(key)); if (!allianceKey) return false; diff --git a/www/hub.css b/www/hub.css index 38b0d586..59ea7f39 100644 --- a/www/hub.css +++ b/www/hub.css @@ -549,7 +549,6 @@ div.tab-bar-scroll { right: 210px; top: 0px; height: 50px; - overflow: scroll; } @@ -707,6 +706,55 @@ div.tab-content { right: 0px; } +/* Timeline */ + +div.timeline { + position: absolute; + top: 50px; + height: 32px; + left: 10px; + right: 10px; +} + +canvas.timeline-canvas { + position: absolute; + left: 0%; + top: 0%; + width: 100%; + height: 100%; + border-radius: 10px; + box-sizing: border-box; + + background-color: #eee; + border: 1px solid #222; +} + +@media (prefers-color-scheme: dark) { + canvas.timeline-canvas { + background-color: #444; + border: 1px solid #eee; + } +} + +div.timeline-scroll { + position: absolute; + left: 0%; + top: 0%; + width: 100%; + height: 100%; + overflow: scroll; + z-index: 9; +} + +div.timeline-scroll::-webkit-scrollbar { + display: none; +} + +div.timeline-scroll-content { + width: 1000000px; + height: 1000000px; +} + /* Source list */ div.source-list { diff --git a/www/hub.html b/www/hub.html index bc5b61ec..8a609594 100644 --- a/www/hub.html +++ b/www/hub.html @@ -99,9 +99,14 @@
-
-
+
+ +
+
+
+ +