Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
CLAUDE.md
.idea/
.playwright-mcp/
PLAN.md
plans/

# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
Expand Down
121 changes: 121 additions & 0 deletions client/src/assets/styles/timeline.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* Shared styles for timeline visualization components (MicTimeline, StageTimeline).
*
* Usage: Import in Vue component <style> block:
* @use '@/assets/styles/timeline';
*
* Then apply the container class to your root element:
* <div class="timeline-container">
*/

.timeline-container {
position: relative;
background-color: var(--body-background);
border: 1px solid #dee2e6;
border-radius: 0.25rem;
overflow: hidden;
min-height: 400px;
height: calc(100vh - 200px);
}

.timeline-wrapper {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}

.timeline-controls-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background-color: rgba(52, 58, 64, 0.95);
border-bottom: 1px solid #6c757d;
z-index: 10;
flex-shrink: 0;
}

.svg-container {
flex: 1;
overflow: auto;
position: relative;
min-height: 0;
}

.timeline-svg {
display: block;
background-color: var(--body-background);
}

// Scene dividers (vertical lines)
.scene-divider {
stroke: #6c757d;
stroke-width: 1;
opacity: 0.6;
shape-rendering: crispEdges;
}

// Act labels
.act-header {
fill: #343a40;
stroke: #6c757d;
stroke-width: 1px;
}

.act-label {
fill: #dee2e6;
font-size: 14px;
font-weight: 600;
pointer-events: none;
user-select: none;
}

// Scene labels
.scene-label {
fill: #adb5bd;
font-size: 11px;
pointer-events: none;
user-select: none;
}

// Row labels
.row-label {
fill: #dee2e6;
font-size: 12px;
font-weight: 500;
pointer-events: none;
user-select: none;
}

// Row separators (horizontal lines)
.row-separator {
stroke: #6c757d;
stroke-width: 1px;
opacity: 0.5;
}

// Allocation bars
.allocation-bar {
stroke: #212529;
stroke-width: 1px;
cursor: pointer;
transition:
opacity 0.2s ease,
stroke-width 0.2s ease;

&:hover {
opacity: 0.8;
stroke-width: 2px;
stroke: #fff;
}
}

.bar-label {
fill: #fff;
font-size: 12px;
font-weight: 600;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8);
user-select: none;
}
224 changes: 224 additions & 0 deletions client/src/mixins/timelineMixin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import { mapGetters } from 'vuex';

/**
* Shared mixin for timeline visualization components (MicTimeline, StageTimeline).
* Provides common SVG rendering utilities, layout calculations, and export functionality.
*
* This mixin assumes the component has:
* - A Vuex store with ACT_BY_ID and ORDERED_SCENES getters
* - A `scenes` computed property returning the ordered list of scenes
* - A `rows` computed property returning items to display as rows
* - A `$refs.svg` reference to the SVG element
*
* Components using this mixin should implement:
* - computed.scenes - array of scene objects in order
* - computed.rows - array of row items (e.g., mics, props, scenery)
* - methods for generating bars specific to their domain
*/
export default {
data() {
return {
margin: {
top: 75,
right: 20,
bottom: 20,
left: 150,
},
sceneWidth: 100,
rowHeight: 50,
barPadding: 6,
};
},

computed: {
...mapGetters(['ACT_BY_ID']),
contentWidth() {
return this.scenes.length * this.sceneWidth;
},
contentHeight() {
return this.rows.length * this.rowHeight;
},
totalWidth() {
return this.margin.left + this.contentWidth + this.margin.right;
},
totalHeight() {
return this.margin.top + this.contentHeight + this.margin.bottom;
},
actGroups() {
const groups = [];
let currentAct = null;
let startIndex = 0;

this.scenes.forEach((scene, index) => {
const act = this.ACT_BY_ID(scene.act);
if (!act) return;

if (currentAct !== act.id) {
if (currentAct !== null) {
groups.push({
actId: currentAct,
actName: this.ACT_BY_ID(currentAct)?.name || 'Unknown',
startX: this.getSceneX(startIndex),
width: this.getSceneX(index) - this.getSceneX(startIndex),
});
}
currentAct = act.id;
startIndex = index;
}
});

// Push final group
if (currentAct !== null) {
groups.push({
actId: currentAct,
actName: this.ACT_BY_ID(currentAct)?.name || 'Unknown',
startX: this.getSceneX(startIndex),
width: this.getSceneX(this.scenes.length) - this.getSceneX(startIndex),
});
}

return groups;
},
},

methods: {
getSceneX(sceneIndex) {
return sceneIndex * this.sceneWidth;
},
getRowY(rowIndex) {
return rowIndex * this.rowHeight;
},
getColorForEntity(entityId, entityType) {
// Golden ratio angle provides optimal distribution across hue spectrum
const GOLDEN_RATIO_CONJUGATE = 137.508;

// Different offsets for different entity types to avoid collisions
const typeOffsets = {
mic: 0,
character: 120,
cast: 240,
prop: 60,
scenery: 180,
};

const offset = typeOffsets[entityType] || 0;
const hue = (entityId * GOLDEN_RATIO_CONJUGATE + offset) % 360;

// Use high saturation and medium lightness for vibrant, visible colors
return `hsl(${hue}, 70%, 50%)`;
},
groupConsecutiveScenes(allocations, sceneIdField = 'scene_id') {
if (!allocations || allocations.length === 0) {
return [];
}

const segments = [];
let currentSegment = null;

this.scenes.forEach((scene, sceneIndex) => {
const hasAllocation = allocations.some((a) => a[sceneIdField] === scene.id);

if (hasAllocation) {
if (currentSegment) {
// Extend current segment
currentSegment.endIndex = sceneIndex;
currentSegment.endScene = scene.name;
} else {
// Start new segment
currentSegment = {
startIndex: sceneIndex,
endIndex: sceneIndex,
startScene: scene.name,
endScene: scene.name,
};
}
} else if (currentSegment) {
// End current segment
segments.push(currentSegment);
currentSegment = null;
}
});

// Push final segment
if (currentSegment) {
segments.push(currentSegment);
}

return segments;
},
exportTimeline(filenamePrefix = 'timeline', viewModeName = '') {
const svgElement = this.$refs.svg;
if (!svgElement) return;

// Create a clone of the SVG to avoid modifying the original
const svgClone = svgElement.cloneNode(true);

// Inline critical styles for export (for print-friendly output)
this.applyExportStyles(svgClone);

// Serialize the SVG to a string
const serializer = new XMLSerializer();
const svgString = serializer.serializeToString(svgClone);

// Create a blob and download link
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();

// Set canvas size to SVG size
canvas.width = this.totalWidth;
canvas.height = this.totalHeight;

img.onload = () => {
// Draw white background for print-friendly output
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);

// Draw the SVG image
ctx.drawImage(img, 0, 0);

// Convert canvas to blob and download
canvas.toBlob((blob) => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const dateSuffix = new Date().toISOString().slice(0, 10);
const modeSuffix = viewModeName ? `-${viewModeName}` : '';
link.download = `${filenamePrefix}${modeSuffix}-${dateSuffix}.png`;
link.href = url;
link.click();
URL.revokeObjectURL(url);
});
};

// Create a data URL from the SVG string
const svgBlob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
const svgUrl = URL.createObjectURL(svgBlob);
img.src = svgUrl;
},
applyExportStyles(svgClone) {
// Define export styles for print-friendly output
const exportStyles = {
'.scene-divider': { stroke: '#495057', 'stroke-width': '1', opacity: '0.4' },
'.row-separator': { stroke: '#495057', 'stroke-width': '1', opacity: '0.3' },
'.act-header': { fill: '#e9ecef', stroke: '#495057', 'stroke-width': '1' },
'.act-label': { fill: '#212529', 'font-size': '14', 'font-weight': '600' },
'.scene-label': { fill: '#495057', 'font-size': '11' },
'.row-label': { fill: '#212529', 'font-size': '12', 'font-weight': '500' },
'.allocation-bar': { stroke: '#212529', 'stroke-width': '1' },
'.bar-label': {
fill: '#ffffff',
'font-weight': '600',
style: 'text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8)',
},
};

Object.entries(exportStyles).forEach(([selector, attrs]) => {
svgClone.querySelectorAll(selector).forEach((el) => {
Object.entries(attrs).forEach(([attr, value]) => {
el.setAttribute(attr, value);
});
});
});
},
},
};
28 changes: 28 additions & 0 deletions client/src/store/modules/stage.js
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,34 @@ export default {
CREW_LIST(state) {
return state.crewList;
},
PROP_BY_ID: (state) => (propId) => {
if (propId == null) return null;
return state.propsList.find((p) => p.id === propId) || null;
},
SCENERY_BY_ID: (state) => (sceneryId) => {
if (sceneryId == null) return null;
return state.sceneryList.find((s) => s.id === sceneryId) || null;
},
PROPS_ALLOCATIONS_BY_ITEM(state) {
const result = {};
state.propsAllocations.forEach((alloc) => {
if (!result[alloc.props_id]) {
result[alloc.props_id] = [];
}
result[alloc.props_id].push(alloc);
});
return result;
},
SCENERY_ALLOCATIONS_BY_ITEM(state) {
const result = {};
state.sceneryAllocations.forEach((alloc) => {
if (!result[alloc.scenery_id]) {
result[alloc.scenery_id] = [];
}
result[alloc.scenery_id].push(alloc);
});
return result;
},
SCENERY_TYPES(state) {
return state.sceneryTypes;
},
Expand Down
Loading
Loading