diff --git a/README.md b/README.md index 91bb700..257c15e 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Visual overlay editor. Drag widgets onto a live preview of your frame, resize th ![Layout editor](docs/screenshots/layout.png) -- **Widget types** — `gauge` (rotating spoke), `vertical_bar` (throttle/brake), `bar` (horizontal channel), `text` (value label or timer) +- **Widget types** — `gauge` (rotating spoke), `bar` (horizontal or vertical via rotation), `text` (value label or timer) - **Sources** — `time` (session clock) plus any channel present in the CSV (`ch1`, `ch2`, … `chN`) - **Preview timeline** — scrub through the session to see live animated widget values - **Zoom & pan** — mouse wheel to zoom, drag the background to pan; zoom controls in the timeline bar diff --git a/docs/screenshots/export.png b/docs/screenshots/export.png index cf4d57c..2d3f17a 100644 Binary files a/docs/screenshots/export.png and b/docs/screenshots/export.png differ diff --git a/docs/screenshots/install.png b/docs/screenshots/install.png index a7a0933..b88e085 100644 Binary files a/docs/screenshots/install.png and b/docs/screenshots/install.png differ diff --git a/docs/screenshots/layout.png b/docs/screenshots/layout.png index d28c6d9..a930e57 100644 Binary files a/docs/screenshots/layout.png and b/docs/screenshots/layout.png differ diff --git a/docs/screenshots/source.png b/docs/screenshots/source.png index 163e2de..b88e085 100644 Binary files a/docs/screenshots/source.png and b/docs/screenshots/source.png differ diff --git a/docs/screenshots/transforms.png b/docs/screenshots/transforms.png index 8bbe18f..3edf74b 100644 Binary files a/docs/screenshots/transforms.png and b/docs/screenshots/transforms.png differ diff --git a/src/main/nativeApi.ts b/src/main/nativeApi.ts index 1fce40f..3ab4c46 100644 --- a/src/main/nativeApi.ts +++ b/src/main/nativeApi.ts @@ -25,8 +25,16 @@ const APP_NAME = "MT12OverlayStudio"; const SETTINGS_FILENAME = "overlay_ui_settings.json"; const DEFAULT_SOURCES = ["time", "ch1", "ch2", "ch3", "ch4"]; const TIME_SOURCE = "time"; -const CHANNEL_WIDGET_TYPES = ["gauge", "vertical_bar", "bar", "text"]; +const CHANNEL_WIDGET_TYPES = ["gauge", "bar", "text"]; const TIME_WIDGET_TYPES = ["text"]; +const LEGACY_VERTICAL_BAR_TO_BAR_SCALE_X = 330 / 220; +const LEGACY_VERTICAL_BAR_TO_BAR_SCALE_Y = 130 / 48; +const BAR_APPEARANCE_DEFAULTS = { + bar_track_fill_thickness: 68, + bar_track_outline_thickness: 3, + bar_center_mark_thickness: 2, + bar_corner_radius: 100, +}; function clamp(value: number, low: number, high: number) { if (!Number.isFinite(value)) return low; @@ -55,13 +63,14 @@ function defaultItemForSource(source: string, itemId: string) { const defaults: Record> = { item_time_1: { source: "time", - name: "time 1", + name: "Timer", label: "TIME", widget: "text", - x: 0.14, - y: 0.08, - scale_x: 1, - scale_y: 1, + x: 0.13, + y: 0.07, + scale_x: 1.15, + scale_y: 1.08, + rotation: 0, accent_color: "#55beff", negative_color: "#55beff", positive_color: "#55beff", @@ -74,13 +83,14 @@ function defaultItemForSource(source: string, itemId: string) { }, item_ch1_1: { source: "ch1", - name: "ch1 1", - label: "CH1", + name: "Steering", + label: "STEER", widget: "gauge", - x: 0.15, - y: 0.78, - scale_x: 1, - scale_y: 1, + x: 0.16, + y: 0.76, + scale_x: 1.15, + scale_y: 1.15, + rotation: 0, accent_color: "#ffd25a", negative_color: "#ffaa54", positive_color: "#55beff", @@ -93,13 +103,14 @@ function defaultItemForSource(source: string, itemId: string) { }, item_ch2_1: { source: "ch2", - name: "ch2 1", - label: "CH2", - widget: "vertical_bar", - x: 0.93, - y: 0.68, - scale_x: 1, - scale_y: 1, + name: "Throttle / brake", + label: "THROTTLE", + widget: "bar", + x: 0.86, + y: 0.72, + scale_x: 1.75, + scale_y: 2.35, + rotation: -90, accent_color: "#40d68c", negative_color: "#ff5c5c", positive_color: "#40d68c", @@ -109,16 +120,18 @@ function defaultItemForSource(source: string, itemId: string) { outline_color: "#ffffff", outline_visible: true, shadow_visible: true, + ...BAR_APPEARANCE_DEFAULTS, }, item_ch3_1: { source: "ch3", - name: "ch3 1", - label: "CH3", + name: "Aux 1", + label: "AUX 1", widget: "bar", - x: 0.84, - y: 0.13, - scale_x: 1, + x: 0.74, + y: 0.08, + scale_x: 1.65, scale_y: 1, + rotation: 0, accent_color: "#55beff", negative_color: "#ffaa54", positive_color: "#55beff", @@ -128,16 +141,18 @@ function defaultItemForSource(source: string, itemId: string) { outline_color: "#ffffff", outline_visible: true, shadow_visible: true, + ...BAR_APPEARANCE_DEFAULTS, }, item_ch4_1: { source: "ch4", - name: "ch4 1", - label: "CH4", + name: "Aux 2", + label: "AUX 2", widget: "bar", - x: 0.84, - y: 0.21, - scale_x: 1, + x: 0.74, + y: 0.15, + scale_x: 1.65, scale_y: 1, + rotation: 0, accent_color: "#ffaa54", negative_color: "#ffaa54", positive_color: "#55beff", @@ -147,6 +162,7 @@ function defaultItemForSource(source: string, itemId: string) { outline_color: "#ffffff", outline_visible: true, shadow_visible: true, + ...BAR_APPEARANCE_DEFAULTS, }, }; if (defaults[itemId]) return { ...defaults[itemId] }; @@ -160,6 +176,7 @@ function defaultItemForSource(source: string, itemId: string) { y: 0.5, scale_x: 1, scale_y: 1, + rotation: 0, accent_color: "#55beff", negative_color: "#ffaa54", positive_color: "#55beff", @@ -171,7 +188,10 @@ function defaultItemForSource(source: string, itemId: string) { shadow_visible: true, }; if (source === "ch2") { - base.widget = "vertical_bar"; + base.widget = "bar"; + base.scale_x = LEGACY_VERTICAL_BAR_TO_BAR_SCALE_X; + base.scale_y = LEGACY_VERTICAL_BAR_TO_BAR_SCALE_Y; + base.rotation = -90; base.accent_color = "#40d68c"; base.negative_color = "#ff5c5c"; base.positive_color = "#40d68c"; @@ -184,7 +204,7 @@ function defaultItemForSource(source: string, itemId: string) { base.negative_color = "#55beff"; base.positive_color = "#55beff"; } - return base; + return base.widget === "bar" ? { ...base, ...BAR_APPEARANCE_DEFAULTS } : base; } function defaultLayout() { @@ -210,17 +230,23 @@ function sanitizeLayout(layout: unknown) { const source = String(userItem.source || (legacyKeys.has(itemId) ? itemId : "ch1")); const normalizedId = legacyKeys.has(itemId) ? `item_${itemId}_1` : itemId; const itemDefaults = (defaults as Record>)[normalizedId] || defaultItemForSource(source, normalizedId); - let widget = String(userItem.widget ?? itemDefaults.widget); + const rawWidget = String(userItem.widget ?? itemDefaults.widget); + const legacyVerticalBar = rawWidget === "vertical_bar"; + let widget = legacyVerticalBar ? "bar" : rawWidget; if (!widgetTypesForSource(source).includes(widget)) widget = String(itemDefaults.widget); - merged[normalizedId] = { + const userScaleX = Number(userItem.scale_x ?? (legacyVerticalBar ? 1 : itemDefaults.scale_x)); + const userScaleY = Number(userItem.scale_y ?? (legacyVerticalBar ? 1 : itemDefaults.scale_y)); + const userRotation = Number(userItem.rotation ?? (legacyVerticalBar ? 0 : itemDefaults.rotation ?? 0)); + const sanitizedItem = { source, name: String(userItem.name ?? itemDefaults.name ?? defaultItemName(source, normalizedId)), label: String(userItem.label ?? itemDefaults.label), widget, x: clamp(Number(userItem.x ?? itemDefaults.x), 0.05, 0.95), y: clamp(Number(userItem.y ?? itemDefaults.y), 0.05, 0.95), - scale_x: clamp(Number(userItem.scale_x ?? itemDefaults.scale_x), 0.2, 12), - scale_y: clamp(Number(userItem.scale_y ?? itemDefaults.scale_y), 0.2, 12), + scale_x: clamp(legacyVerticalBar ? userScaleY * LEGACY_VERTICAL_BAR_TO_BAR_SCALE_X : userScaleX, 0.2, 12), + scale_y: clamp(legacyVerticalBar ? userScaleX * LEGACY_VERTICAL_BAR_TO_BAR_SCALE_Y : userScaleY, 0.2, 12), + rotation: clamp(legacyVerticalBar ? userRotation - 90 : userRotation, -180, 180), accent_color: String(userItem.accent_color ?? itemDefaults.accent_color), negative_color: String(userItem.negative_color ?? itemDefaults.negative_color), positive_color: String(userItem.positive_color ?? itemDefaults.positive_color), @@ -235,6 +261,15 @@ function sanitizeLayout(layout: unknown) { ...(userItem.range_center !== undefined ? { range_center: Number(userItem.range_center) } : {}), ...(userItem.range_max !== undefined ? { range_max: Number(userItem.range_max) } : {}), }; + if (widget === "bar") { + Object.assign(sanitizedItem, { + bar_track_fill_thickness: clamp(Number(userItem.bar_track_fill_thickness ?? itemDefaults.bar_track_fill_thickness ?? BAR_APPEARANCE_DEFAULTS.bar_track_fill_thickness), 5, 100), + bar_track_outline_thickness: clamp(Number(userItem.bar_track_outline_thickness ?? itemDefaults.bar_track_outline_thickness ?? BAR_APPEARANCE_DEFAULTS.bar_track_outline_thickness), 0, 24), + bar_center_mark_thickness: clamp(Number(userItem.bar_center_mark_thickness ?? itemDefaults.bar_center_mark_thickness ?? BAR_APPEARANCE_DEFAULTS.bar_center_mark_thickness), 0, 24), + bar_corner_radius: clamp(Number(userItem.bar_corner_radius ?? itemDefaults.bar_corner_radius ?? BAR_APPEARANCE_DEFAULTS.bar_corner_radius), 0, 100), + }); + } + merged[normalizedId] = sanitizedItem; } return Object.keys(merged).length ? merged : {}; } @@ -699,7 +734,7 @@ async function renderOverlay(payload: Record, emit: EmitFn) { const durationMs = Number(payload.duration_ms) || 0; const renderVideo = Boolean(payload.render_video); const ffmpegPath = String(payload.ffmpeg_path || ""); - const layout = (payload.layout ?? {}) as Record; + const layout = sanitizeLayout(payload.layout); const { samples } = loadSamples(csvPath, offsetMs); const runningStatsArray = buildRunningStatsArray(samples); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 42757f9..64a53fa 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -26,7 +26,6 @@ import { clamp, defaultSettings, fallbackMetadata, - HANDLE_CURSORS, interpolateLocalState, itemBounds, numeric, @@ -288,8 +287,9 @@ function App() { setBusy(true); setProgress(null); try { - await api.saveSettings(settings); - const result = await api.renderOverlay(settings as Record); + const saved = await api.saveSettings(settings); + setSettings((current) => ({ ...current, ...saved })); + const result = await api.renderOverlay(saved as Record); pushLog(t("logs.rendered", { count: result.frame_count })); } catch (error) { pushLog(error instanceof Error ? error.message : String(error)); @@ -418,12 +418,65 @@ function App() { return { x, y, frameWidth: outputWidth, frameHeight: outputHeight }; } + function rotatedPoint(x: number, y: number, cx: number, cy: number, degrees: number) { + const radians = (degrees * Math.PI) / 180; + const cos = Math.cos(radians); + const sin = Math.sin(radians); + const dx = x - cx; + const dy = y - cy; + return { x: cx + dx * cos - dy * sin, y: cy + dx * sin + dy * cos }; + } + + function rotateOffset(x: number, y: number, degrees: number) { + const radians = (degrees * Math.PI) / 180; + const cos = Math.cos(radians); + const sin = Math.sin(radians); + return { x: x * cos - y * sin, y: x * sin + y * cos }; + } + + function pointInItemBounds(x: number, y: number, item: LayoutItem, bounds: [number, number, number, number]) { + const [left, top, right, bottom] = bounds; + const cx = (left + right) / 2; + const cy = (top + bottom) / 2; + const local = rotatedPoint(x, y, cx, cy, -(item.rotation ?? 0)); + return left <= local.x && local.x <= right && top <= local.y && local.y <= bottom; + } + + function resizeHandlePoints(item: LayoutItem, bounds: [number, number, number, number]): [HandleId, number, number][] { + const [l, t, r, b] = bounds; + const mx = (l + r) / 2; + const my = (t + b) / 2; + const rotation = item.rotation ?? 0; + const point = (id: HandleId, x: number, y: number): [HandleId, number, number] => { + const rotated = rotatedPoint(x, y, mx, my, rotation); + return [id, rotated.x, rotated.y]; + }; + return [ + point("nw", l, t), point("n", mx, t), point("ne", r, t), + point("w", l, my), point("e", r, my), + point("sw", l, b), point("s", mx, b), point("se", r, b), + ]; + } + + function resizeCursor(handle: HandleId, rotation = 0) { + const baseAxis: Record = { + e: 0, w: 0, + n: 90, s: 90, + nw: 45, se: 45, + ne: 135, sw: 135, + }; + const axis = ((baseAxis[handle] + rotation) % 180 + 180) % 180; + if (axis < 22.5 || axis >= 157.5) return "ew-resize"; + if (axis < 67.5) return "nwse-resize"; + if (axis < 112.5) return "ns-resize"; + return "nesw-resize"; + } + function locateItemAt(x: number, y: number) { for (const [id, item] of [...layoutItems].reverse()) { const [left, top, right, bottom] = itemBounds(item, outputWidth, outputHeight); - if (left <= x && x <= right && top <= y && y <= bottom) { - return { id, bounds: [left, top, right, bottom] as [number, number, number, number] }; - } + const bounds = [left, top, right, bottom] as [number, number, number, number]; + if (pointInItemBounds(x, y, item, bounds)) return { id, bounds }; } return null; } @@ -485,19 +538,12 @@ function App() { if (!selectedItemId || !previewStageRef.current) return null; const item = settings.layout[selectedItemId]; if (!item) return null; - const [l, t, r, b] = boundsForPreviewItem(selectedItemId, item); - const mx = (l + r) / 2; - const my = (t + b) / 2; + const bounds = boundsForPreviewItem(selectedItemId, item); const stageRect = previewStageRef.current.getBoundingClientRect(); const hit = 10 * (outputWidth / (stageRect.width / previewZoom)); - const pts: [HandleId, number, number][] = [ - ["nw", l, t], ["n", mx, t], ["ne", r, t], - ["w", l, my], ["e", r, my], - ["sw", l, b], ["s", mx, b], ["se", r, b], - ]; - for (const [id, hx, hy] of pts) { - if (Math.abs(frameX - hx) <= hit && Math.abs(frameY - hy) <= hit) return id; + for (const [id, hx, hy] of resizeHandlePoints(item, bounds)) { + if (Math.hypot(frameX - hx, frameY - hy) <= hit) return id; } return null; } @@ -536,13 +582,19 @@ function App() { const item = settings.layout[selectedItemId]; if (item) { const [l, t, r, b] = boundsForPreviewItem(selectedItemId, item); + const width = r - l; + const height = b - t; resizingRef.current = { itemId: selectedItemId, handle, - resizeX: (handle === "n" || handle === "s") ? null : { fixedEnd: handle.includes("w") ? r : l }, - resizeY: (handle === "e" || handle === "w") ? null : { fixedEnd: handle.includes("n") ? b : t }, - origX: item.x, origY: item.y, + fixedLocalX: (handle === "n" || handle === "s") ? null : handle.includes("w") ? width / 2 : -width / 2, + fixedLocalY: (handle === "e" || handle === "w") ? null : handle.includes("n") ? height / 2 : -height / 2, + origCenterX: (l + r) / 2, + origCenterY: (t + b) / 2, + origWidth: width, + origHeight: height, origScaleX: item.scale_x, origScaleY: item.scale_y, + rotation: item.rotation ?? 0, }; event.currentTarget.setPointerCapture(event.pointerId); return; @@ -569,56 +621,68 @@ function App() { if (resizingRef.current) { const point = previewPointerToFrame(event); if (!point) return; - const { itemId, handle, resizeX, resizeY, origX, origY, origScaleX, origScaleY } = resizingRef.current; + const { + itemId, + handle, + fixedLocalX, + fixedLocalY, + origCenterX, + origCenterY, + origWidth, + origHeight, + origScaleX, + origScaleY, + rotation, + } = resizingRef.current; const item = settings.layout[itemId]; if (!item) return; const [baseW, baseH] = widgetSize(item.widget); const sc = Math.max(0.01, Math.min(outputWidth / 1920, outputHeight / 1080)); const MIN_W = 32, MIN_H = 24; - let newX = origX, newScaleX = origScaleX; - let newY = origY, newScaleY = origScaleY; - const isCornerResize = handle.length === 2 && resizeX && resizeY; - - if (isCornerResize) { - const px = clamp(point.x, 0, outputWidth); - const py = clamp(point.y, 0, outputHeight); - const originalW = Math.max(MIN_W, baseW * sc * origScaleX); - const originalH = Math.max(MIN_H, baseH * sc * origScaleY); - const minFactor = Math.max(0.2 / origScaleX, 0.2 / origScaleY, MIN_W / originalW, MIN_H / originalH); + const localPointer = rotatedPoint(point.x, point.y, origCenterX, origCenterY, -rotation); + let movingLocalX = localPointer.x - origCenterX; + let movingLocalY = localPointer.y - origCenterY; + let nextWidth = origWidth; + let nextHeight = origHeight; + let centerLocalX = 0; + let centerLocalY = 0; + + const resizingX = fixedLocalX !== null; + const resizingY = fixedLocalY !== null; + const isCornerResize = handle.length === 2 && resizingX && resizingY; + + if (isCornerResize && fixedLocalX !== null && fixedLocalY !== null) { + const rawWidth = Math.abs(movingLocalX - fixedLocalX); + const rawHeight = Math.abs(movingLocalY - fixedLocalY); + const minFactor = Math.max(0.2 / origScaleX, 0.2 / origScaleY, MIN_W / origWidth, MIN_H / origHeight); const maxFactor = Math.min(12 / origScaleX, 12 / origScaleY); - const factor = clamp( - Math.max(Math.abs(px - resizeX.fixedEnd) / originalW, Math.abs(py - resizeY.fixedEnd) / originalH), - minFactor, - maxFactor, - ); - const signX = handle.includes("w") ? -1 : 1; - const signY = handle.includes("n") ? -1 : 1; - const w = originalW * factor; - const h = originalH * factor; - const movingX = resizeX.fixedEnd + signX * w; - const movingY = resizeY.fixedEnd + signY * h; - newX = clamp(((resizeX.fixedEnd + movingX) / 2) / outputWidth, 0.01, 0.99); - newY = clamp(((resizeY.fixedEnd + movingY) / 2) / outputHeight, 0.01, 0.99); - newScaleX = clamp(origScaleX * factor, 0.2, 12); - newScaleY = clamp(origScaleY * factor, 0.2, 12); - scheduleResizePreview({ itemId, x: newX, y: newY, scaleX: newScaleX, scaleY: newScaleY }); - return; - } - - if (resizeX) { - const px = clamp(point.x, 0, outputWidth); - const w = Math.max(MIN_W, Math.abs(px - resizeX.fixedEnd)); - newX = clamp(((px + resizeX.fixedEnd) / 2) / outputWidth, 0.01, 0.99); - newScaleX = clamp(w / (baseW * sc), 0.2, 12); + const factor = clamp(Math.max(rawWidth / origWidth, rawHeight / origHeight), minFactor, maxFactor); + nextWidth = origWidth * factor; + nextHeight = origHeight * factor; + movingLocalX = fixedLocalX + (handle.includes("w") ? -nextWidth : nextWidth); + movingLocalY = fixedLocalY + (handle.includes("n") ? -nextHeight : nextHeight); + centerLocalX = (fixedLocalX + movingLocalX) / 2; + centerLocalY = (fixedLocalY + movingLocalY) / 2; + } else { + if (fixedLocalX !== null) { + nextWidth = clamp(Math.abs(movingLocalX - fixedLocalX), MIN_W, baseW * sc * 12); + movingLocalX = fixedLocalX + (handle.includes("w") ? -nextWidth : nextWidth); + centerLocalX = (fixedLocalX + movingLocalX) / 2; + } + + if (fixedLocalY !== null) { + nextHeight = clamp(Math.abs(movingLocalY - fixedLocalY), MIN_H, baseH * sc * 12); + movingLocalY = fixedLocalY + (handle.includes("n") ? -nextHeight : nextHeight); + centerLocalY = (fixedLocalY + movingLocalY) / 2; + } } - if (resizeY) { - const py = clamp(point.y, 0, outputHeight); - const h = Math.max(MIN_H, Math.abs(py - resizeY.fixedEnd)); - newY = clamp(((py + resizeY.fixedEnd) / 2) / outputHeight, 0.01, 0.99); - newScaleY = clamp(h / (baseH * sc), 0.2, 12); - } + const centerOffset = rotateOffset(centerLocalX, centerLocalY, rotation); + const newX = clamp((origCenterX + centerOffset.x) / outputWidth, 0.01, 0.99); + const newY = clamp((origCenterY + centerOffset.y) / outputHeight, 0.01, 0.99); + const newScaleX = clamp(nextWidth / (baseW * sc), 0.2, 12); + const newScaleY = clamp(nextHeight / (baseH * sc), 0.2, 12); scheduleResizePreview({ itemId, x: newX, y: newY, scaleX: newScaleX, scaleY: newScaleY }); return; @@ -649,7 +713,8 @@ function App() { if (point) { const handle = locateResizeHandle(point.x, point.y); if (handle) { - setPreviewCursor(HANDLE_CURSORS[handle]); + const item = selectedItemId ? settings.layout[selectedItemId] : undefined; + setPreviewCursor(resizeCursor(handle, item?.rotation ?? 0)); return; } const found = summary && layoutItems.length ? locateItemAt(point.x, point.y) : null; diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index ababe33..d7b29a6 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -57,9 +57,20 @@ "sectionTransform": "Transformation", "subgroupPosition": "Position", "subgroupSize": "Größe", + "subgroupRotation": "Drehung", + "rotation": "Drehung", "sectionDataPipeline": "Datenpipeline", "appearance": "Erscheinungsbild", "shadow": "Schatten", + "subgroupBarFills": "Füllungen", + "subgroupBarAppearance": "BAR-Form", + "controlColor": "Farbe", + "controlThickness": "Stärke", + "controlThicknessPercent": "Stärke (%)", + "barTrackFillThickness": "Spur (%)", + "barTrackOutlineThickness": "Kontur", + "barCenterMarkThickness": "Mitte", + "barCornerRadius": "Radius (%)", "selectOrAdd": "Widget auswählen oder hinzufügen", "zoomOut": "Herauszoomen", "resetZoom": "Zoom zurücksetzen", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 439abcb..d1873b0 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -57,9 +57,20 @@ "sectionTransform": "Transform", "subgroupPosition": "Position", "subgroupSize": "Size", + "subgroupRotation": "Rotation", + "rotation": "Rotation", "sectionDataPipeline": "Data pipeline", "appearance": "Appearance", "shadow": "Shadow", + "subgroupBarFills": "Fills", + "subgroupBarAppearance": "BAR shape", + "controlColor": "Color", + "controlThickness": "Thickness", + "controlThicknessPercent": "Thickness (%)", + "barTrackFillThickness": "Track (%)", + "barTrackOutlineThickness": "Outline", + "barCenterMarkThickness": "Center mark", + "barCornerRadius": "Radius (%)", "selectOrAdd": "Select or add a widget", "zoomOut": "Zoom out", "resetZoom": "Reset zoom", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index 4f39af7..f4827ce 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -57,9 +57,20 @@ "sectionTransform": "Transformación", "subgroupPosition": "Posición", "subgroupSize": "Tamaño", + "subgroupRotation": "Rotación", + "rotation": "Rotación", "sectionDataPipeline": "Pipeline de datos", "appearance": "Apariencia", "shadow": "Sombra", + "subgroupBarFills": "Rellenos", + "subgroupBarAppearance": "Forma BAR", + "controlColor": "Color", + "controlThickness": "Grosor", + "controlThicknessPercent": "Grosor (%)", + "barTrackFillThickness": "Pista (%)", + "barTrackOutlineThickness": "Contorno", + "barCenterMarkThickness": "Marca central", + "barCornerRadius": "Redondeado (%)", "selectOrAdd": "Selecciona o añade un widget", "zoomOut": "Alejar", "resetZoom": "Restablecer zoom", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index b23b3c2..16b5742 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -57,9 +57,20 @@ "sectionTransform": "Transformation", "subgroupPosition": "Position", "subgroupSize": "Taille", + "subgroupRotation": "Rotation", + "rotation": "Rotation", "sectionDataPipeline": "Pipeline de données", "appearance": "Apparence", "shadow": "Ombre", + "subgroupBarFills": "Remplissages", + "subgroupBarAppearance": "Forme BAR", + "controlColor": "Couleur", + "controlThickness": "Epaisseur", + "controlThicknessPercent": "Epaisseur (%)", + "barTrackFillThickness": "Piste (%)", + "barTrackOutlineThickness": "Contour", + "barCenterMarkThickness": "Repère central", + "barCornerRadius": "Arrondi (%)", "selectOrAdd": "Sélectionner ou ajouter un widget", "zoomOut": "Dézoomer", "resetZoom": "Réinitialiser le zoom", diff --git a/src/renderer/styles.css b/src/renderer/styles.css index e32e00f..5fc107d 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -622,12 +622,18 @@ p { position: absolute; } -.widget-chrome.selected { +.widget-frame { + position: absolute; + inset: 0; + transform-origin: center center; +} + +.widget-frame.selected { outline: 2px dashed #ffd25a; outline-offset: 4px; } -.preview.draggable:active .widget-chrome.selected { +.preview.draggable:active .widget-frame.selected { outline-style: solid; box-shadow: 0 0 0 4px rgba(255, 210, 90, 0.16), 0 16px 34px rgba(0, 0, 0, 0.34); } @@ -1132,6 +1138,32 @@ p { flex-shrink: 0; } +.bar-appearance-groups { + display: flex; + flex-direction: column; + gap: 6px; +} + +.bar-appearance-group { + display: flex; + flex-direction: column; + gap: 2px; +} + +.bar-appearance-group + .bar-appearance-group { + border-top: 1px solid #1e2c36; + padding-top: 6px; +} + +.bar-appearance-heading { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #4a6070; + margin-bottom: 1px; +} + .wrap { flex-wrap: wrap; } diff --git a/src/renderer/utils.ts b/src/renderer/utils.ts index 3be4077..dee7f10 100644 --- a/src/renderer/utils.ts +++ b/src/renderer/utils.ts @@ -17,13 +17,22 @@ export const HANDLE_CURSORS: Record = { export type ResizePreview = { itemId: string; x: number; y: number; scaleX: number; scaleY: number }; +export const BAR_APPEARANCE_DEFAULTS = { + trackFillThickness: 68, + trackOutlineThickness: 3, + centerMarkThickness: 2, + cornerRadius: 100, +}; + export type ResizingState = { itemId: string; handle: HandleId; - resizeX: { fixedEnd: number } | null; - resizeY: { fixedEnd: number } | null; - origX: number; origY: number; + fixedLocalX: number | null; + fixedLocalY: number | null; + origCenterX: number; origCenterY: number; + origWidth: number; origHeight: number; origScaleX: number; origScaleY: number; + rotation: number; }; export const defaultSettings: AppSettings = { @@ -42,34 +51,135 @@ export const defaultSettings: AppSettings = { export const fallbackMetadata: AppMetadata = { sources: ["time", "ch1", "ch2", "ch3", "ch4"], - channel_widget_types: ["gauge", "vertical_bar", "bar", "text"], + channel_widget_types: ["gauge", "bar", "text"], time_widget_types: ["text"], }; -export const fallbackItem: LayoutItem = { - source: "ch1", - name: "ch1 1", - label: "CH1", - widget: "gauge", - x: 0.15, - y: 0.78, - scale_x: 1, - scale_y: 1, - accent_color: "#ffd25a", - negative_color: "#ffaa54", - positive_color: "#55beff", - text_color: "#ffffff", - bg_color: "#141a20", - bg_visible: true, - outline_color: "#ffffff", - outline_visible: true, - shadow_visible: true, +const fallbackBarAppearance = { + bar_track_fill_thickness: BAR_APPEARANCE_DEFAULTS.trackFillThickness, + bar_track_outline_thickness: BAR_APPEARANCE_DEFAULTS.trackOutlineThickness, + bar_center_mark_thickness: BAR_APPEARANCE_DEFAULTS.centerMarkThickness, + bar_corner_radius: BAR_APPEARANCE_DEFAULTS.cornerRadius, }; +export const fallbackLayout: Record = { + item_time_1: { + source: "time", + name: "Timer", + label: "TIME", + widget: "text", + x: 0.13, + y: 0.07, + scale_x: 1.15, + scale_y: 1.08, + rotation: 0, + accent_color: "#55beff", + negative_color: "#55beff", + positive_color: "#55beff", + text_color: "#ffffff", + bg_color: "#141a20", + bg_visible: true, + outline_color: "#ffffff", + outline_visible: true, + shadow_visible: true, + }, + item_ch1_1: { + source: "ch1", + name: "Steering", + label: "STEER", + widget: "gauge", + x: 0.16, + y: 0.76, + scale_x: 1.15, + scale_y: 1.15, + rotation: 0, + accent_color: "#ffd25a", + negative_color: "#ffaa54", + positive_color: "#55beff", + text_color: "#ffffff", + bg_color: "#141a20", + bg_visible: true, + outline_color: "#ffffff", + outline_visible: true, + shadow_visible: true, + }, + item_ch2_1: { + source: "ch2", + name: "Throttle / brake", + label: "THROTTLE", + widget: "bar", + x: 0.86, + y: 0.72, + scale_x: 1.75, + scale_y: 2.35, + rotation: -90, + accent_color: "#40d68c", + negative_color: "#ff5c5c", + positive_color: "#40d68c", + text_color: "#ffffff", + bg_color: "#141a20", + bg_visible: true, + outline_color: "#ffffff", + outline_visible: true, + shadow_visible: true, + ...fallbackBarAppearance, + }, + item_ch3_1: { + source: "ch3", + name: "Aux 1", + label: "AUX 1", + widget: "bar", + x: 0.74, + y: 0.08, + scale_x: 1.65, + scale_y: 1, + rotation: 0, + accent_color: "#55beff", + negative_color: "#ffaa54", + positive_color: "#55beff", + text_color: "#ffffff", + bg_color: "#141a20", + bg_visible: true, + outline_color: "#ffffff", + outline_visible: true, + shadow_visible: true, + ...fallbackBarAppearance, + }, + item_ch4_1: { + source: "ch4", + name: "Aux 2", + label: "AUX 2", + widget: "bar", + x: 0.74, + y: 0.15, + scale_x: 1.65, + scale_y: 1, + rotation: 0, + accent_color: "#ffaa54", + negative_color: "#ffaa54", + positive_color: "#55beff", + text_color: "#ffffff", + bg_color: "#141a20", + bg_visible: true, + outline_color: "#ffffff", + outline_visible: true, + shadow_visible: true, + ...fallbackBarAppearance, + }, +}; + +export const fallbackItem: LayoutItem = fallbackLayout.item_ch1_1; + +function fallbackLayoutClone() { + return Object.fromEntries( + Object.entries(fallbackLayout).map(([id, item]) => [id, { ...item }]), + ) as Record; +} + export const browserFallbackApi: OverlayApi = { metadata: async () => fallbackMetadata, - defaultLayout: async () => ({ layout: { item_ch1_1: fallbackItem } }), - loadSettings: async () => ({ ...defaultSettings, layout: { item_ch1_1: fallbackItem } }), + defaultLayout: async () => ({ layout: fallbackLayoutClone() }), + loadSettings: async () => ({ ...defaultSettings, layout: fallbackLayoutClone() }), saveSettings: async (settings) => settings, chooseCsv: async () => null, chooseDirectory: async () => null, @@ -86,7 +196,7 @@ export const browserFallbackApi: OverlayApi = { renderOverlay: async () => ({ frame_count: 0, output_dir: "", video_output: "" }), discoverRadios: async () => ({ sources: [] }), listRadioLogs: async () => ({ logs: [] }), - createWidget: async () => ({ item_id: `item_ch1_${Date.now()}`, item: fallbackItem }), + createWidget: async () => ({ item_id: `item_ch1_${Date.now()}`, item: { ...fallbackItem } }), discoverFfmpeg: async () => ({ path: null, source: "not found" }), downloadFfmpeg: async () => { throw new Error("Not available in browser"); }, installScripts: async () => { throw new Error("Not available in browser"); }, @@ -111,7 +221,6 @@ export function widgetSize(widget: string) { text: [280, 52], bar: [220, 48], gauge: [250, 250], - vertical_bar: [130, 330], }; return sizes[widget] || [180, 60]; } @@ -155,18 +264,6 @@ export function colorControlLabel(item: LayoutItem, key: ColorKey): string | nul return labels[key]; } - if (item.widget === "vertical_bar") { - const labels: Record = { - accent_color: null, - negative_color: "colors.negativeFill", - positive_color: "colors.positiveFill", - text_color: "colors.centerMark", - bg_color: "colors.trackFill", - outline_color: "colors.trackOutline", - }; - return labels[key]; - } - if (item.widget === "bar") { const labels: Record = { accent_color: null, diff --git a/src/renderer/views/LayoutView.tsx b/src/renderer/views/LayoutView.tsx index 4b393f5..8afcaee 100644 --- a/src/renderer/views/LayoutView.tsx +++ b/src/renderer/views/LayoutView.tsx @@ -5,6 +5,7 @@ import type { AppMetadata, AppSettings, CsvSummary, FrameState, LayoutItem } fro import type { RunningStats } from "../../shared/widgetDraw"; import { WidgetCanvas } from "../components/WidgetCanvas"; import { + BAR_APPEARANCE_DEFAULTS, clamp, colorControlLabel, itemName, @@ -93,6 +94,71 @@ export function LayoutView(props: LayoutViewProps) { onGoToSource, } = props; + const barNumberControl = ( + key: "bar_track_fill_thickness" | "bar_track_outline_thickness" | "bar_center_mark_thickness" | "bar_corner_radius", + labelKey: string, + value: number, + min: number, + max: number, + step: number, + ) => { + const label = String(t(labelKey)); + const update = (raw: string) => onUpdateSelectedItem(key, clamp(Number(raw), min, max) as LayoutItem[typeof key]); + return ( +
+ {label} + update(e.target.value)} + /> + update(e.target.value)} + /> +
+ ); + }; + + const colorControl = (item: LayoutItem, key: ColorKey, label: string) => { + const isOff = + (key === "bg_color" && item.bg_visible === false) || + (key === "outline_color" && item.outline_visible === false); + const hasToggle = key === "bg_color" || key === "outline_color"; + const toggleKey = key === "bg_color" ? "bg_visible" : "outline_visible"; + return ( +
+ {label} +
+ {hasToggle && ( + onUpdateSelectedItem(toggleKey as keyof LayoutItem, e.target.checked as LayoutItem[keyof LayoutItem])} + /> + )} +
+ onUpdateSelectedItem(key as keyof LayoutItem, e.target.value as never)} + /> +
+
+
+ ); + }; + const durationMs = summary?.duration_ms ?? 0; return ( @@ -136,7 +202,7 @@ export function LayoutView(props: LayoutViewProps) { return (
{itemName(id, item)} - {sel && (["nw","n","ne","e","se","s","sw","w"] as HandleId[]).map((h) => ( -
- ))} +
+ {sel && (["nw","n","ne","e","se","s","sw","w"] as HandleId[]).map((h) => ( +
+ ))} +
); })} @@ -344,6 +415,16 @@ export function LayoutView(props: LayoutViewProps) { updateH(Number(e.target.value))} />
+
{t("layout.subgroupRotation")}
+
+ {t("layout.rotation")} + onUpdateSelectedNumber("rotation", e.target.value, -180, 180)} /> + onUpdateSelectedNumber("rotation", e.target.value, -180, 180)} /> +
); })()} @@ -361,40 +442,69 @@ export function LayoutView(props: LayoutViewProps) { {t("layout.appearance")}
-
- {(["accent_color","negative_color","positive_color","text_color","bg_color","outline_color"] as ColorKey[]) - .map((key) => [key, colorControlLabel(selectedItem, key)] as const) - .filter((entry): entry is readonly [ColorKey, string] => entry[1] !== null) - .map(([key, labelKey]) => { - const isOff = - (key === "bg_color" && selectedItem.bg_visible === false) || - (key === "outline_color" && selectedItem.outline_visible === false); - const hasToggle = key === "bg_color" || key === "outline_color"; - const toggleKey = key === "bg_color" ? "bg_visible" : "outline_visible"; - return ( -
- {t(labelKey)} -
- {hasToggle && ( - onUpdateSelectedItem(toggleKey as keyof LayoutItem, e.target.checked as LayoutItem[keyof LayoutItem])} - /> - )} -
- onUpdateSelectedItem(key as keyof LayoutItem, e.target.value as never)} - /> -
-
-
- ); - })} -
+ {selectedItem.widget === "bar" ? ( +
+
+
{t("layout.subgroupBarFills")}
+ {colorControl(selectedItem, "negative_color", String(t("colors.negativeFill")))} + {colorControl(selectedItem, "positive_color", String(t("colors.positiveFill")))} +
+
+
{t("colors.centerMark")}
+ {colorControl(selectedItem, "text_color", String(t("layout.controlColor")))} + {barNumberControl( + "bar_center_mark_thickness", + "layout.controlThickness", + selectedItem.bar_center_mark_thickness ?? BAR_APPEARANCE_DEFAULTS.centerMarkThickness, + 0, + 24, + 1, + )} +
+
+
{t("colors.trackFill")}
+ {colorControl(selectedItem, "bg_color", String(t("layout.controlColor")))} + {barNumberControl( + "bar_track_fill_thickness", + "layout.controlThicknessPercent", + selectedItem.bar_track_fill_thickness ?? BAR_APPEARANCE_DEFAULTS.trackFillThickness, + 5, + 100, + 1, + )} +
+
+
{t("colors.trackOutline")}
+ {colorControl(selectedItem, "outline_color", String(t("layout.controlColor")))} + {barNumberControl( + "bar_track_outline_thickness", + "layout.controlThickness", + selectedItem.bar_track_outline_thickness ?? BAR_APPEARANCE_DEFAULTS.trackOutlineThickness, + 0, + 24, + 1, + )} +
+
+
{t("layout.subgroupBarAppearance")}
+ {barNumberControl( + "bar_corner_radius", + "layout.barCornerRadius", + selectedItem.bar_corner_radius ?? BAR_APPEARANCE_DEFAULTS.cornerRadius, + 0, + 100, + 1, + )} +
+
+ ) : ( +
+ {(["accent_color","negative_color","positive_color","text_color","bg_color","outline_color"] as ColorKey[]) + .map((key) => [key, colorControlLabel(selectedItem, key)] as const) + .filter((entry): entry is readonly [ColorKey, string] => entry[1] !== null) + .map(([key, labelKey]) => colorControl(selectedItem, key, String(t(labelKey))))} +
+ )}
diff --git a/src/shared/types.ts b/src/shared/types.ts index 89c744c..3b33ed0 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -76,6 +76,7 @@ export type LayoutItem = { y: number; scale_x: number; scale_y: number; + rotation?: number; accent_color: string; negative_color: string; positive_color: string; @@ -85,6 +86,10 @@ export type LayoutItem = { outline_color: string; outline_visible?: boolean; shadow_visible?: boolean; + bar_track_fill_thickness?: number; + bar_track_outline_thickness?: number; + bar_center_mark_thickness?: number; + bar_corner_radius?: number; transforms?: string[]; range_min?: number; range_center?: number; diff --git a/src/shared/widgetDraw.ts b/src/shared/widgetDraw.ts index 2a9a927..76c39dd 100644 --- a/src/shared/widgetDraw.ts +++ b/src/shared/widgetDraw.ts @@ -111,6 +111,15 @@ function rgba(hex: string, alpha: number): string { /** CSS: background-color: ${bg_color}aa → 0xAA/0xFF ≈ 0.6667 */ const BG_ALPHA = 170 / 255; +const BAR_TRACK_FILL_THICKNESS = 68; +const BAR_TRACK_OUTLINE_THICKNESS = 3; +const BAR_CENTER_MARK_THICKNESS = 2; +const BAR_CORNER_RADIUS = 100; + +function optionalNumber(value: number | undefined, fallback: number, low: number, high: number): number { + const parsed = Number(value ?? fallback); + return clamp(Number.isFinite(parsed) ? parsed : fallback, low, high); +} function formatValue(value: number): string { const v = Math.round(value); @@ -122,7 +131,6 @@ function widgetBaseSize(widget: string): [number, number] { text: [280, 52], bar: [220, 48], gauge: [250, 250], - vertical_bar: [130, 330], }; return sizes[widget] ?? [180, 60]; } @@ -235,76 +243,20 @@ function drawGauge(ctx: DrawCtx, item: LayoutItem, value: number, w: number, h: } -// ─── Vertical bar ───────────────────────────────────────────────────────────── - -function drawVerticalBar(ctx: DrawCtx, item: LayoutItem, value: number, w: number, h: number, sc: number) { - const trackW = 0.68 * w; - const trackH = 0.90 * h; - const trackX = (w - trackW) / 2; - const trackY = (h - trackH) / 2; - const bw = Math.max(1, 3 * sc); - const innerInset = bw; - const fillInset = 8 * sc; - - if (item.shadow_visible !== false) { - ctx.shadowColor = "rgba(0,0,0,0.25)"; - ctx.shadowOffsetX = 0; - ctx.shadowOffsetY = 10 * sc; - ctx.shadowBlur = 28 * sc; - } - - rrect(ctx, trackX + bw / 2, trackY + bw / 2, trackW - bw, trackH - bw, trackW / 2); - if (item.bg_visible !== false) { - ctx.fillStyle = rgba(item.bg_color, BG_ALPHA); - ctx.fill(); - } - ctx.shadowColor = "transparent"; - - ctx.save(); - rrect(ctx, trackX + innerInset, trackY + innerInset, trackW - innerInset * 2, trackH - innerInset * 2, (trackW - innerInset * 2) / 2); - ctx.clip(); - - const innerH = trackH - innerInset * 2; - const midY = trackY + innerInset + innerH / 2; - const fillH = Math.abs(value) * (innerH / 2); - const fillColor = value >= 0 ? item.positive_color : item.negative_color; - if (fillH > 0.5) { - rrect( - ctx, - trackX + innerInset + fillInset, - value >= 0 ? midY - fillH : midY, - trackW - innerInset * 2 - fillInset * 2, - fillH, - (trackW - innerInset * 2 - fillInset * 2) / 2, - ); - ctx.fillStyle = fillColor; - ctx.fill(); - } - - // Center tick line (geometric guide, always visible) - ctx.fillStyle = item.text_color; - ctx.fillRect(trackX + innerInset + fillInset, midY - sc, trackW - innerInset * 2 - fillInset * 2, Math.max(1, 2 * sc)); - - ctx.restore(); - - if (item.outline_visible !== false) { - rrect(ctx, trackX + bw / 2, trackY + bw / 2, trackW - bw, trackH - bw, trackW / 2); - ctx.strokeStyle = item.outline_color; - ctx.lineWidth = bw; - ctx.stroke(); - } -} - -// ─── Bar (horizontal mirror of vertical_bar) ───────────────────────────────── +// ─── Bar ───────────────────────────────────────────────────────────────────── function drawBar(ctx: DrawCtx, item: LayoutItem, value: number, w: number, h: number, sc: number) { const trackW = 0.90 * w; - const trackH = 0.68 * h; + const trackFillPct = optionalNumber(item.bar_track_fill_thickness, BAR_TRACK_FILL_THICKNESS, 5, 100) / 100; + const trackH = Math.max(1, trackFillPct * h); const trackX = (w - trackW) / 2; const trackY = (h - trackH) / 2; - const bw = Math.max(1, 3 * sc); - const innerInset = bw; - const fillInset = 8 * sc; + const outlineW = item.outline_visible !== false + ? Math.min(optionalNumber(item.bar_track_outline_thickness, BAR_TRACK_OUTLINE_THICKNESS, 0, 24) * sc, trackW / 2, trackH / 2) + : 0; + const innerInset = outlineW; + const cornerPct = optionalNumber(item.bar_corner_radius, BAR_CORNER_RADIUS, 0, 100) / 100; + const outerRadius = (trackH / 2) * cornerPct; if (item.shadow_visible !== false) { ctx.shadowColor = "rgba(0,0,0,0.25)"; @@ -313,7 +265,7 @@ function drawBar(ctx: DrawCtx, item: LayoutItem, value: number, w: number, h: nu ctx.shadowBlur = 28 * sc; } - rrect(ctx, trackX + bw / 2, trackY + bw / 2, trackW - bw, trackH - bw, trackH / 2); + rrect(ctx, trackX + outlineW / 2, trackY + outlineW / 2, trackW - outlineW, trackH - outlineW, outerRadius); if (item.bg_visible !== false) { ctx.fillStyle = rgba(item.bg_color, BG_ALPHA); ctx.fill(); @@ -322,36 +274,45 @@ function drawBar(ctx: DrawCtx, item: LayoutItem, value: number, w: number, h: nu // Clip fill to the inner track area ctx.save(); - rrect(ctx, trackX + innerInset, trackY + innerInset, trackW - innerInset * 2, trackH - innerInset * 2, (trackH - innerInset * 2) / 2); + const innerW = Math.max(0, trackW - innerInset * 2); + const innerH = Math.max(0, trackH - innerInset * 2); + const innerRadius = (innerH / 2) * cornerPct; + rrect(ctx, trackX + innerInset, trackY + innerInset, innerW, innerH, innerRadius); ctx.clip(); - const innerW = trackW - innerInset * 2; const midX = trackX + innerInset + innerW / 2; const fillW = Math.abs(value) * (innerW / 2); + const fillInset = Math.min(8 * sc, Math.max(0, (innerH - sc) / 2)); + const fillH = Math.max(0, innerH - fillInset * 2); + const fillY = trackY + innerInset + fillInset; + const fillRadius = (fillH / 2) * cornerPct; const fillColor = value >= 0 ? item.positive_color : item.negative_color; - if (fillW > 0.5) { + if (fillW > 0.5 && fillH > 0.5) { rrect( ctx, value >= 0 ? midX : midX - fillW, - trackY + innerInset + fillInset, + fillY, fillW, - trackH - innerInset * 2 - fillInset * 2, - (trackH - innerInset * 2 - fillInset * 2) / 2, + fillH, + fillRadius, ); ctx.fillStyle = fillColor; ctx.fill(); } // Center tick line (geometric guide, always visible) - ctx.fillStyle = item.text_color; - ctx.fillRect(midX - sc, trackY + innerInset + fillInset, Math.max(1, 2 * sc), trackH - innerInset * 2 - fillInset * 2); + const centerMarkW = optionalNumber(item.bar_center_mark_thickness, BAR_CENTER_MARK_THICKNESS, 0, 24) * sc; + if (centerMarkW > 0 && fillH > 0.5) { + ctx.fillStyle = item.text_color; + ctx.fillRect(midX - centerMarkW / 2, fillY, centerMarkW, fillH); + } ctx.restore(); - if (item.outline_visible !== false) { - rrect(ctx, trackX + bw / 2, trackY + bw / 2, trackW - bw, trackH - bw, trackH / 2); + if (item.outline_visible !== false && outlineW > 0) { + rrect(ctx, trackX + outlineW / 2, trackY + outlineW / 2, trackW - outlineW, trackH - outlineW, outerRadius); ctx.strokeStyle = item.outline_color; - ctx.lineWidth = bw; + ctx.lineWidth = outlineW; ctx.stroke(); } } @@ -390,8 +351,12 @@ function drawWidget( const h = bottom - top; const sc = clamp(Math.min(fw / 1920, fh / 1080), 0.1, 8); + const rotation = (optionalNumber(item.rotation, 0, -180, 180) * Math.PI) / 180; + ctx.save(); - ctx.translate(left, top); + ctx.translate(left + w / 2, top + h / 2); + if (rotation !== 0) ctx.rotate(rotation); + ctx.translate(-w / 2, -h / 2); drawBackground(ctx, w, h, item, sc); if (item.source === "time") { @@ -404,7 +369,6 @@ function drawWidget( const norm = clamp(v / normDiv, -1, 1); switch (item.widget) { case "gauge": drawGauge(ctx, item, norm, w, h, sc); break; - case "vertical_bar": drawVerticalBar(ctx, item, norm, w, h, sc); break; case "bar": drawBar(ctx, item, norm, w, h, sc); break; default: drawTextWidget(ctx, item, v, w, h, sc); break; }