common code

This commit is contained in:
2025-09-17 12:03:14 -04:00
parent 090af0f477
commit 50376f71a8
22 changed files with 3145 additions and 390 deletions

View File

@@ -0,0 +1,4 @@
{
"tabWidth": 2,
"useTabs": false
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,12 +10,29 @@
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/material": "^7.3.2",
"@mui/x-date-pickers": "^8.11.2",
"@mui/x-date-pickers-pro": "^8.11.2",
"@wojtekmaj/react-daterange-picker": "^7.0.0",
"d3": "^7.9.0",
"date-fns": "^4.1.0",
"dayjs": "^1.11.18",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.2",
"flex-layout-system": "^2.0.3",
"react": "^19.1.1",
"react-calendar": "^6.0.0",
"react-date-range": "^2.0.1",
"react-datepicker": "^8.7.0",
"react-dom": "^19.1.1",
"timelines-chart": "^2.14.2"
"react-flexbox-grid": "^2.1.2",
"react-split-pane": "^0.1.92",
"rsuite": "^5.83.3",
"timelines-chart": "^2.14.2",
"uplot": "^1.6.32",
"uplot-react": "^1.2.4"
},
"devDependencies": {
"@eslint/js": "^9.33.0",

View File

@@ -1,6 +1,114 @@
/* Root container */
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
width: 100vw;
max-width: 100vw;
margin: 0;
padding: 0;
text-align: center;
}
background: #181a20;
}
/* Video.js player */
.video-js-mod {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
object-fit: contain;
background: #000;
}
.vjs-tech {
object-fit: contain;
}
/* Main app layout */
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
margin: 0;
padding: 0;
gap: 12px;
/* background: #181a20; */
}
.flex-group {
display: flex;
flex-direction: column; /* or 'row' if you want horizontal grouping */
flex: 1 1 0;
min-width: 0;
}
/* Section containers */
.section-box-horiz {
overflow: visible;
flex-direction: row;
display: flex;
align-items: center;
justify-content: center;
}
/* Section containers */
.section-box {
flex: 0 0 5%;
overflow: visible;
/* background: #23272f; */
/* padding: 0;
box-sizing: border-box;
border-radius: 10px;
margin: 0 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.10); */
display: flex;
align-items: center;
justify-content: center;
}
.timeline-container {
flex: 0 0 24%;
overflow: visible;
background: #20232a;
padding: 0;
box-sizing: border-box;
border-radius: 10px;
margin: 0 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.10);
display: flex;
align-items: center;
justify-content: center;
}
.section-box:last-of-type {
flex: 1 1 68%;
overflow: hidden;
background: #23272f;
padding: 0;
box-sizing: border-box;
border-radius: 10px;
margin: 0 16px 16px 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.10);
display: flex;
align-items: center;
justify-content: center;
}
/* Responsive tweaks */
@media (max-width: 600px) {
.app-container {
gap: 6px;
}
.section-box,
.timeline-container,
.section-box:last-of-type {
margin: 0 4px;
border-radius: 6px;
padding: 0;
}
.date-range-selector {
max-width: 98vw;
padding: 12px 8px;
border-radius: 8px;
}
}

View File

@@ -1,14 +1,289 @@
import React from 'react';
import EmbedTimeline from './components/EmbedTimeline';
import './App.css';
import data_results from "./util/embed_results_web.json"
"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import EmbedTimeline from "./components/EmbedTimeline";
import VideoPlayer from "./components/VideoPlayer";
// import ModernDateRangeSelector from './components/ModernDateRangeSelector';
import CompactDateRangePicker from "./components/CompactDateRangePicker";
import CustomDateRangePicker from "./components/DateRangePicker";
import "./App.css";
import StatusesDisplayHUD from "./components/StatusDisplay";
function App() {
const original_data = useRef(null);
const chartRef = useRef(null);
const [dataResults, setDataResults] = useState(null);
const [statusMessages, setStatusMessages] = useState([]);
const [markerTime, setMarkerTime] = useState(0);
const playerRef = useRef(null);
const playerInstanceRef = useRef(null);
// State for the values
window.chartRef = chartRef;
window.playerRef = playerRef;
window.playerInstanceRef = playerInstanceRef;
// Slider states
const [sliderMin, setSliderMin] = useState(0.0);
const [sliderMax, setSliderMax] = useState(1.0);
// Date range states
//
const [startRange, setStartRange] = useState(
new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000)
);
const [endRange, setEndRange] = useState(new Date());
// const [endRange, setEndRange] = useState(new Date(new Date().getTime() - 6 * 24 * 60 * 60 * 1000));
const [queryText, setQueryText] = useState("A clouded leopard and a human");
const [sliderValue, setSliderValue] = useState(0);
// State to track last submitted values
const [lastSubmitted, setLastSubmitted] = useState({
startRange,
endRange,
sliderValue,
queryText,
});
// Check if any value has changed
const hasChanged =
startRange !== lastSubmitted.startRange ||
endRange !== lastSubmitted.endRange ||
sliderValue !== lastSubmitted.sliderValue ||
queryText !== lastSubmitted.queryText;
// Function to resubmit fetch
const handleResubmit = () => {
// Start streaming status updates
fetch("api/return_status")
.then((response) => {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = ""; // Accumulate partial text
function read() {
reader.read().then(({ done, value }) => {
if (done) {
if (buffer) {
// console.log("Status:", buffer); // Log any remaining text
}
setStatusMessages([]);
// console.log("Status stream finished");
return;
}
// Decode only the new chunk
buffer += decoder.decode(value, { stream: true });
// If your server sends lines, split and log only complete lines:
let lines = buffer.split("\n");
buffer = lines.pop(); // Save incomplete line for next chunk
for (const line of lines) {
if (line.trim()) {
// console.log("Status:", line);
console.log(line)
setStatusMessages((msgs) => [...msgs, JSON.parse(line)]);
}
}
read();
});
}
read();
})
.catch((error) => {
console.error("Error while streaming status:", error);
});
const params = new URLSearchParams();
params.append("startRange", startRange.toISOString());
params.append("endRange", endRange.toISOString());
params.append("threshold", 0.0);
params.append("query", queryText);
setDataResults({ videos: [], breaks: [] });
fetch("api/videos.json?" + params.toString())
.then((res) => res.json())
.then((data) => {
const max_value = Math.max(
...data["videos"].map((vid) => vid["embed_scores"]["score"][1])
);
setSliderMax(max_value);
original_data.current = data;
window.original_data = original_data;
setDataResults(data);
});
setLastSubmitted({ startRange, endRange, sliderValue, queryText });
};
function updateDataAndValue(newValue) {
const floatValue = parseFloat(newValue);
setSliderValue(floatValue);
var newData = JSON.parse(JSON.stringify(original_data.current));
newData["videos"] = newData["videos"].filter(
(vid) => vid["embed_scores"]["score"][1] >= floatValue
);
setDataResults(newData);
}
function setMarkerValueNonReactive(inputValue) {
let chart = chartRef.current.getEchartsInstance();
let options = chart.getOption();
let mappers = options["mappers"];
let vv = {
xAxis: mappers["real_to_virtual"](new Date(inputValue)),
lineStyle: { type: "solid", color: "#FF0000", width: 2 },
label: {
show: false,
formatter: "Break",
position: "bottom",
color: "#888",
fontSize: 10,
},
};
let markLine = {
symbol: ["none", "none"],
data: [vv],
lineStyle: { type: "dashed", color: "#FF0000", width: 2 },
silent: true,
animation: false,
};
// if ("markLine" in options["series"][1]) {
if (false) {
let vv_new = {
xAxis: mappers["real_to_virtual"](new Date(inputValue)),
};
let markLine_new = {
data: [vv_new],
};
chart.setOption(
{
series: [{}, { markLine: { data: [vv_new] } }],
},
false,
["series.markLine"]
);
} else {
chart.setOption(
{
series: [{}, { markLine: markLine }],
},
false,
["series.markLine"]
);
}
}
// Memoize the timeline click handler
const handleTimelineClick = useCallback(
(path, timeoffset) => {
console.log("Timeline clicked:", path, timeoffset);
if (playerRef.current && playerInstanceRef.current) {
console.log("Seeking video player to:", path, timeoffset);
playerInstanceRef.current.src({
src: "api/" + path,
type: "video/mp4",
});
playerInstanceRef.current.on("loadedmetadata", () => {
playerInstanceRef.current.currentTime(timeoffset);
});
}
},
[] // Empty dependency array since it only uses playerRef
);
useEffect(() => {
const params = new URLSearchParams(window.location.search); // id=123
if (params.get("test_mode") == "true") {
setStartRange(new Date(new Date().getTime() - 2 * 24 * 60 * 60 * 1000));
setEndRange(new Date(new Date().getTime() - 1 * 24 * 60 * 60 * 1000));
}
handleResubmit();
}, []);
return (
<div className="App">
<h1>Embed Timeline Visualization</h1>
<EmbedTimeline data_in={data_results}/>
<div className="app-container">
<div className="section-box-horiz">
<div className="flex-group">
<CustomDateRangePicker
startDate={startRange}
endDate={endRange}
setStartRange={setStartRange}
setEndRange={setEndRange}
/>
</div>
<div className="flex-group">
<input
type="text"
placeholder="Enter query"
value={queryText}
onChange={(e) => setQueryText(e.target.value)}
style={{
marginLeft: "16px",
marginRight: "16px",
padding: "8px",
borderRadius: "4px",
border: "1px solid #343a40",
color: "#fff", // Text white
backgroundColor: "#23272f", // Optional: dark background for contrast
}}
/>
</div>
<div className="flex-group">
<label
style={{ marginLeft: "8px", marginRight: "8px", color: "#fff" }}
>
Threshold:
</label>
</div>
<div className="flex-group">
<input
type="range"
min={sliderMin}
max={sliderMax}
step={0.001}
value={sliderValue}
onChange={(e) => updateDataAndValue(e.target.value)}
style={{
width: "120px",
color: "#fff", // Text white
backgroundColor: "#23272f", // Optional: dark background for contrast
}}
/>
</div>
<div className="flex-group">
<span style={{ marginLeft: "8px", color: "#fff" }}>
{sliderValue.toFixed(2)}
</span>
</div>
<div className="flex-group">
<button onClick={handleResubmit}>Resubmit</button>
</div>
</div>
<div>
<StatusesDisplayHUD statusMessages={statusMessages} />
</div>
<div className="timeline-container">
<EmbedTimeline
chartRef={chartRef}
data_in={dataResults}
onTimelineClick={handleTimelineClick}
/>
</div>
<div className="section-box">
<VideoPlayer
videoRef={playerRef}
playerInstanceRef={playerInstanceRef}
setMarkerTimeFunc={setMarkerValueNonReactive}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import React, { useState } from "react";
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
export default function CompactDateRangePicker({ startDate, endDate, setStartDate, setEndDate}) {
// const [startDate, setStartDate] = useState(null);
// const [endDate, setEndDate] = useState(null);
console.log(startDate)
console.log(endDate)
console.log(setStartDate)
console.log(setEndDate)
return (
<DatePicker
selectsRange
startDate={startDate}
endDate={endDate}
onChange={([start, end]) => {
setStartDate(start);
setEndDate(end);
if (end && onChange) onChange({ startDate: start, endDate: end });
}}
isClearable
maxDate={new Date()}
placeholderText="Select date range"
withPortal
/>
);
}

View File

@@ -0,0 +1,77 @@
import React, { useState, useRef, useEffect } from "react";
import "react-date-range/dist/styles.css"; // main css file
import "react-date-range/dist/theme/default.css"; // theme css file
import { DateRange } from "react-date-range";
export default function CustomDateRangePicker({ startDate, endDate, setStartRange, setEndRange }) {
const minDate = new Date("2025-07-01")
const maxDate = new Date()
const [showCalendar, setShowCalendar] = useState(false);
const calendarRef = useRef(null);
// Create range object for react-date-range
const range = [{
startDate: startDate,
endDate: endDate,
key: 'selection'
}];
const handleSelect = (ranges) => {
const { startDate: newStart, endDate: newEnd } = ranges.selection;
setStartRange(newStart);
setEndRange(newEnd);
if (
newStart &&
newEnd &&
newStart.getTime() !== newEnd.getTime()
) {
setShowCalendar(false);
}
};
// Hide calendar when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (
calendarRef.current &&
!calendarRef.current.contains(event.target)
) {
setShowCalendar(false);
}
};
if (showCalendar) {
document.addEventListener("mousedown", handleClickOutside);
} else {
document.removeEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [showCalendar]);
return (
<div ref={calendarRef} style={{ position: "relative" }}>
<button onClick={() => setShowCalendar((prev) => !prev)}>
{startDate?.toLocaleDateString()} -{" "}
{endDate?.toLocaleDateString()}
</button>
{showCalendar && (
<div style={{ position: "absolute", zIndex: 10 }}>
<DateRange
minDate={minDate}
maxDate={maxDate}
ranges={range}
onChange={handleSelect}
moveRangeOnFirstSelection={false}
/>
</div>
)}
</div>
);
}

View File

@@ -1,28 +0,0 @@
.time-block {
opacity: 0.5;
stroke-width: 1;
fill: #4CAF50;
stroke: #2E7D32;
}
.score-line {
fill: none;
stroke-width: 2;
stroke: #4CAF50;
}
.score-dot {
r: 4;
stroke: white;
stroke-width: 1;
fill: #4CAF50;
}
.axis {
font-size: 12px;
}
.grid-line {
stroke: #e0e0e0;
stroke-dasharray: 2,2;
opacity: 0.7;
}

View File

@@ -1,361 +1,481 @@
import React, { useRef, useEffect, useState } from 'react';
import ReactECharts from 'echarts-for-react';
// import './EmbedTimeline.css';
import React, { useRef, useEffect } from "react";
import ReactECharts from "echarts-for-react";
const EmbedTimeline = React.memo(function EmbedTimeline({
chartRef,
data_in,
onTimelineClick,
markerTime,
}) {
// --- Early return if loading ---
if (!data_in) return <div>Loading....</div>;
export default function EmbedTimeline({ data_in }) {
// --- Constants ---
const BREAK_GAP = 0;
console.log("REDRAW");
const timeFormatOptions = {
withSeconds: {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "numeric",
hour12: true,
},
edges: {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
},
within: {
hour: "numeric",
minute: "2-digit",
hour12: true,
},
};
var result = []
const mean = (data) => {
if (data.length < 1) {
return;
}
return data.reduce((prev, current) => prev + current) / data.length;
};
function prepareVideoData(videos) {
let new_data = [];
videos.forEach((item) => {
let start_time = new Date(1000 * item["start_time"]);
if ("embed_scores" in item) {
var mean_val = item["embed_scores"]["time"] / 2;
// var max_score = Math.max(...item['embed_scores']['score'])
var max_score = item["embed_scores"]["score"][1];
var max_score_time = new Date(
start_time.getTime() + 1000 * item["embed_scores"]["score"][3]
);
var new_time = new Date(start_time.getTime() + 1000 * 2 * mean_val);
new_data.push([
new Date(start_time.getTime()),
new_time,
max_score,
max_score_time,
]);
// new_data.push([new_time, item['embed_scores']['score'][idx]]);
for (let idx_outer = 0; idx_outer < data_in.length; idx_outer++) {
let item = data_in[idx_outer]
let start_time = Date.parse(item["start_time"])
var new_data = [];
if ('embed_scores' in item) {
for (let idx = 0; idx < item['embed_scores']['time'].length; idx++) {
var new_time = 1000 * item['embed_scores']['time'][idx] + start_time
// Math.max.apply(Math, item['embed_scores']['time'].map(function(o) { return o.y; }))
// item['embed_scores']['time'].forEach((sec, idx) => {
// let new_time = new Date(start_time.getTime() + 1000 * sec);
new_data.push([new_time, item['embed_scores']['score'][idx]])
}
}
result.push(new_data)
// new_data.push([new_time, item['embed_scores']['score'][idx]]);
// });
}
});
// Remove duplicates and sort
return Array.from(new Set(new_data.map(JSON.stringify)), JSON.parse).sort(
(a, b) => new Date(a[0]) - new Date(b[0])
);
}
function calculateBreaks(videos) {
const breaks = [];
if (videos.length < 3) {
return breaks;
}
let t_diff = videos.at(-1)["end_time"] - videos[0]["start_time"];
for (let i = 0; i < videos.length - 1; i++) {
let end_now = videos[i]["end_time"];
let start_next = videos[i + 1]["start_time"];
if (start_next - end_now > 60 * 60) {
// still in unix timestamp. break only if spaces of 60 minutes
breaks.push([end_now, start_next]);
}
}
function reframe_data(item, idx) {
return breaks;
}
function fillNulls(data) {
const with_nulls = [];
for (let i = 0; i < data.length; i++) {
with_nulls.push(data[i]);
if (i < data.length - 1) {
const curr_time = new Date(data[i][0]).getTime();
const next_time = new Date(data[i + 1][0]).getTime();
if (next_time - curr_time > 1000) {
// with_nulls.push([new Date(curr_time + 1), null]);
// with_nulls.push([new Date(curr_time + 1), 0]);
}
}
}
return with_nulls;
}
function prepareBreaks(breaksRaw) {
return breaksRaw.map(([start, end]) => ({
start: new Date(1000 * start),
end: new Date(1000 * end),
gap: BREAK_GAP,
isExpanded: false,
}));
}
function buildVirtualTimeMapper(breaks) {
const sortedBreaks = breaks.slice().sort((a, b) => a.start - b.start);
return function (realDate) {
let offset = 0;
let realMs = realDate.getTime();
for (const br of sortedBreaks) {
if (realMs >= br.end.getTime()) {
offset += br.end.getTime() - br.start.getTime();
} else if (realMs > br.start.getTime()) {
offset += realMs - br.start.getTime();
break;
}
}
return realMs - offset;
};
}
function mapVirtualToRealTime(virtualMs, breaks, virtualTime) {
let realMs = virtualMs;
for (const br of breaks) {
const breakStartVirtual = virtualTime(br.start);
const breakDuration = br.end.getTime() - br.start.getTime();
if (virtualMs >= breakStartVirtual) {
realMs += breakDuration;
}
}
return realMs;
}
function buildSeries(item, idx) {
const data = item.map(function (item, index) {
return {
value: item,
};
});
console.log(data)
return {
type: "custom",
renderItem: function (params, api) {
var yValue = api.value(2);
var start = api.coord([api.value(0), yValue]);
var size = api.size([api.value(1) - api.value(0), yValue]);
var style = api.style();
var maxTime = api.coord([api.value(3), yValue]);
return {
type: 'line',
symbol: 'none',
smooth: true,
lineStyle: {
normal: {
color: 'green',
width: 1,
}
type: "group",
children: [
{
type: "rect",
shape: {
x: start[0],
y: start[1],
width: size[0],
height: size[1],
},
style: { fill: "#00F0003F" },
},
data: item
}
}
const series_out = result.map(reframe_data)
{
type: "circle",
shape: { cx: maxTime[0], cy: maxTime[1], r: 1 },
style: { fill: "#00F0003F" },
},
],
};
},
symbol: "none",
smooth: true,
large: true,
lineStyle: { normal: { color: "green", width: 1 } },
// data: item.map(d => [d[0], d[1], d[2], d[3]]),
data: data,
// sampling: 'lttb',
triggerLineEvent: true,
z: 11,
};
}
const option = {
xAxis: {
type: 'time',
boundaryGap: false
},
yAxis: {
type: 'value'
},
dataZoom: [
function buildInvisibleHitBoxSeries(item, idx) {
const data = item.map(function (item, index) {
return {
value: item,
};
});
return {
type: "custom",
renderItem: function (params, api) {
var yValue = api.value(2);
var start = api.coord([api.value(0), yValue]);
var size = api.size([api.value(1) - api.value(0), yValue]);
var style = api.style();
var maxTime = api.coord([api.value(3), yValue]);
return {
type: "group",
children: [
{
type: 'inside',
start: 0,
end: 100
type: "rect",
shape: {
x: start[0],
y: start[1],
width: size[0],
height: size[1],
},
style: { fill: "#00F0003F" },
},
{
start: 0,
end: 100
}
],
series: series_out
type: "circle",
shape: { cx: maxTime[0], cy: maxTime[1], r: 1 },
style: { fill: "#00F0003F" },
},
],
};
},
symbol: "none",
smooth: true,
// large: true,
lineStyle: { normal: { color: "green", width: 1 } },
// data: item.map(d => [d[0], d[1], d[2], d[3]]),
data: data,
// sampling: 'lttb',
triggerLineEvent: true,
z: 11,
};
return (
<div>
<ReactECharts option={option} style={{ height: 400 }} />
</div>
);
}
// return {
// type: 'line',
// symbol: 'none',
// smooth: true,
// large: true,
// lineStyle: { width: 100, opacity: 0 },
// data: item.map(d => [d[0], d[1]]),
// sampling: 'lttb',
// triggerLineEvent: true,
// z: 10
// };
}
function buildBlankSeries() {
return {
type: "line",
symbol: "none",
lineStyle: { width: 100, opacity: 0 },
data: [],
z: 4,
};
}
// --- Data Processing ---
const videoData = prepareVideoData(data_in["videos"]);
const withNulls = videoData;
data_in["calc_breaks"] = calculateBreaks(data_in["videos"]);
// const withNulls = fillNulls(videoData);
const breaks = prepareBreaks(data_in["calc_breaks"]);
const virtualTime = buildVirtualTimeMapper(breaks);
// const EmbedTimeline = ({ data }) => {
// const containerRef = useRef(null);
const breaks_split = data_in["calc_breaks"].flat(1).map(function (x) {
return x * 1000;
});
// if (videoData.length > 2) {
// breaks_split.unshift(new Date(videoData[0][0]).getTime())
// breaks_split.push(new Date(videoData.at(-1)[0]).getTime())
// }
const paired_splits = [];
for (let i = 0; i < breaks_split.length; i += 2) {
paired_splits.push([
breaks_split[i],
breaks_split[i + 1],
breaks_split[i] / 2 + breaks_split[i + 1] / 2,
]);
}
const split_centers = paired_splits.map((d) => new Date(d[2]));
const splitCenterVirtualTimes = split_centers.map((d) => virtualTime(d));
const splitCenterLabels = split_centers.map((d) =>
new Date(d).toLocaleTimeString("en-US", timeFormatOptions.edges)
);
// useEffect(() => {
// if (!containerRef.current) return;
const splitCenterMarkLines = splitCenterVirtualTimes.map((vt, i) => ({
xAxis: vt,
// make the line invisible
lineStyle: { width: 0, color: "transparent" },
// show the precomputed text
label: {
show: true,
formatter: splitCenterLabels[i],
position: "end", // try other values if overlap; 'end', 'insideStartTop', etc.
color: "#FFFFFF",
fontSize: 11,
rotate: 90,
},
}));
// var myChart = ReactECharts.init(containerRef);
const virtualData = withNulls.map(
([realStartTime, realEndTime, value, realMaxTime]) => [
virtualTime(new Date(realStartTime)),
virtualTime(new Date(realEndTime)),
value,
virtualTime(new Date(realMaxTime)),
]
);
const result = [virtualData];
const ymax = Math.max(...virtualData.map((d) => d[2]));
// --- Series ---
const seriesNormal = result.map(buildSeries);
// const seriesInvisible = result.map(buildInvisibleHitBoxSeries);
const series_out = [].concat(seriesNormal, buildBlankSeries());
// // Specify the configuration items and data for the chart
// var option = {
// title: {
// text: 'ECharts Getting Started Example'
// },
// tooltip: {},
// legend: {
// data: ['sales']
// },
// xAxis: {
// data: ['Shirts', 'Cardigans', 'Chiffons', 'Pants', 'Heels', 'Socks']
// },
// yAxis: {},
// series: [
// {
// name: 'sales',
// type: 'bar',
// data: [5, 20, 36, 10, 10, 20]
// }
// ]
// };
// --- Break MarkLines ---
const breakMarkLines = breaks.map((br) => ({
xAxis: virtualTime(br.start),
lineStyle: { type: "dashed", color: "#888", width: 2 },
label: {
show: true,
formatter: "Break",
position: "bottom",
color: "#888",
fontSize: 10,
},
}));
// // Display the chart using the configuration items and data just specified.
// myChart.setOption(option);
// });
// return (
// <div ref={containerRef}>
// </div>
// )
// }
// export default EmbedTimeline;
// Attach break mark lines to the first series
if (seriesNormal[0]) {
seriesNormal[0].markLine = {
symbol: ["none", "none"],
data: [...(breakMarkLines || []), ...(splitCenterMarkLines || [])],
lineStyle: { type: "dashed", color: "#888", width: 2 },
label: { show: true, position: "bottom", color: "#888", fontSize: 10 },
};
}
// --- Axis & Chart Option ---
const virtual_x_min = virtualData.length > 0 ? virtualData[0][0] : 0;
const virtual_x_max =
virtualData.length > 0 ? virtualData[virtualData.length - 1][0] : 1;
const option = {
animation: false,
// progressive: 0, // Disable progressive rendering
progressiveThreshold: 100000 , // Disable progressive threshold
mappers: {
virtual_to_real: mapVirtualToRealTime,
real_to_virtual: virtualTime,
},
response: true,
grid: {
top: 30, // Remove top padding
left: 10,
right: 20,
bottom: 60,
containLabel: true,
},
dataZoom: [
{
type: "slider",
show: true,
xAxisIndex: [0],
startValue: virtual_x_min,
endValue: virtual_x_max,
filterMode: 'weakFilter',
},
{
type: "inside",
xAxisIndex: [0],
startValue: virtual_x_min,
endValue: virtual_x_max,
filterMode: 'weakFilter',
},
],
xAxis: {
type: "value",
min: virtual_x_min,
max: virtual_x_max,
splitLine: { show: false },
axisLabel: {
formatter: function (virtualMs) {
let range = virtual_x_max - virtual_x_min;
if (
chartRef &&
chartRef.current &&
chartRef.current.getEchartsInstance
) {
const chart = chartRef.current.getEchartsInstance();
const dz = chart.getOption().dataZoom?.[0];
if (
dz &&
dz.startValue !== undefined &&
dz.endValue !== undefined
) {
range = dz.endValue - dz.startValue;
}
}
const realTime = mapVirtualToRealTime(virtualMs, breaks, virtualTime);
if (realTime) {
const useSeconds = range < 5 * 60 * 1000;
const fmt = useSeconds
? timeFormatOptions.withSeconds
: timeFormatOptions.edges;
return new Date(realTime).toLocaleTimeString("en-US", fmt);
}
return "";
},
},
},
yAxis: {
type: "value",
min: 0.0,
max: ymax,
splitLine: { show: false },
},
series: series_out.map((s) => ({
...s,
animation: false, // Disable animation for each series
animationDuration: 0,
})),
};
// const EmbedTimelineF = ({ data }) => {
// const svgRef = useRef(null);
// const containerRef = useRef(null);
// const [showLabels, setShowLabels] = useState(true);
// const [zoomLevel, setZoomLevel] = useState(1);
// --- Chart Event Handlers ---
async function onChartClick(params, echarts) {
const nativeEvent = params.event.event;
const pixel = [nativeEvent.offsetX, nativeEvent.offsetY];
const dataCoord = echarts.convertFromPixel({ seriesIndex: 0 }, pixel);
// useEffect(() => {
// if (!svgRef.current) return;
const res = await fetch("/api/events/click", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
timestamp:
mapVirtualToRealTime(dataCoord[0], breaks, virtualTime) / 1000,
}),
});
if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
const { path, timeoffset } = await res.json();
if (onTimelineClick)
onTimelineClick(path, virtualTime(new Date(timeoffset)));
}
// // Clear any existing SVG content
// d3.select(svgRef.current).selectAll("*").remove();
// // Parse dates and prepare data
// const parseTime = d3.timeParse("%Y-%m-%dT%H:%M:%S.%f");
// const parseTimeAlt = d3.timeParse("%Y-%m-%dT%H:%M:%S");
// const parseDate = (dateStr) => {
// return parseTime(dateStr) || parseTimeAlt(dateStr);
// };
// const processedData = data.map((d, i) => ({
// id: i,
// startTime: parseDate(d.start_time),
// endTime: parseDate(d.end_time),
// scores: d.embed_scores.time.map((time, j) => ({
// time: parseDate(time),
// score: d.embed_scores.score[j]
// }))
// }));
// // Set up dimensions
// const margin = { top: 50, right: 50, bottom: 50, left: 50 };
// const plotHeight = 300;
// const blockHeight = 30;
// const baseWidth = 1000;
// const width = baseWidth * zoomLevel - margin.left - margin.right;
// // Find overall time range
// const allTimes = processedData.flatMap(d => [d.startTime, d.endTime, ...d.scores.map(s => s.time)]);
// const timeExtent = d3.extent(allTimes);
// // Add some padding to time range
// const timePadding = (timeExtent[1] - timeExtent[0]) * 0.02;
// timeExtent[0] = new Date(timeExtent[0].getTime() - timePadding);
// timeExtent[1] = new Date(timeExtent[1].getTime() + timePadding);
// // Find score range
// const allScores = processedData.flatMap(d => d.scores.map(s => s.score));
// const scoreExtent = d3.extent(allScores);
// const scorePadding = (scoreExtent[1] - scoreExtent[0]) * 0.1;
// scoreExtent[0] -= scorePadding;
// scoreExtent[1] += scorePadding;
// // Create main SVG
// const svg = d3.select(svgRef.current)
// .attr("width", width + margin.left + margin.right)
// .attr("height", plotHeight + margin.top + margin.bottom);
// const mainGroup = svg.append("g")
// .attr("transform", `translate(${margin.left}, ${margin.top})`);
// // Create scales
// const xScale = d3.scaleTime()
// .domain(timeExtent)
// .range([0, width]);
// const yScoreScale = d3.scaleLinear()
// .domain(scoreExtent)
// .range([plotHeight * 0.6, 0]);
// // Zoom functionality
// const zoom = d3.zoom()
// .scaleExtent([1, 20])
// .on("zoom", (event) => {
// // Update zoom transform
// const newTransform = event.transform;
// // Update x-scale with zoom
// const newXScale = newTransform.rescaleX(xScale);
// // Function to update visualization elements
// const updateVisualization = () => {
// // Update score lines
// mainGroup.selectAll(".score-line")
// .attr("d", d3.line()
// .x(s => newXScale(s.time))
// .y(s => yScoreScale(s.score))
// );
// // Update score dots
// mainGroup.selectAll(".score-dot")
// .attr("cx", s => newXScale(s.time));
// // Update time blocks
// mainGroup.selectAll(".time-block")
// .attr("x", d => newXScale(d.startTime))
// .attr("width", d => Math.max(2, newXScale(d.endTime) - newXScale(d.startTime)));
// // Update time labels if visible
// if (showLabels) {
// mainGroup.selectAll(".block-start-label")
// .attr("x", d => newXScale(d.startTime));
// mainGroup.selectAll(".block-end-label")
// .attr("x", d => newXScale(d.endTime));
// }
// // Update x-axis
// mainGroup.select(".x-axis").call(
// d3.axisBottom(newXScale)
// .ticks(8)
// .tickFormat(d3.timeFormat("%H:%M:%S"))
// );
// };
// // Apply updates
// updateVisualization();
// });
// // Add zoom behavior
// svg.call(zoom);
// // Create line generator
// const line = d3.line()
// .x(d => xScale(d.time))
// .y(d => yScoreScale(d.score))
// .curve(d3.curveMonotoneX);
// // Add grid lines
// const yTicks = yScoreScale.ticks(6);
// mainGroup.selectAll(".grid-line-y")
// .data(yTicks)
// .enter()
// .append("line")
// .attr("class", "grid-line")
// .attr("x1", 0)
// .attr("x2", width)
// .attr("y1", d => yScoreScale(d))
// .attr("y2", d => yScoreScale(d));
// // Add score lines and dots
// processedData.forEach((d, i) => {
// // Score line
// mainGroup.append("path")
// .datum(d.scores)
// .attr("class", `score-line`)
// .attr("d", line);
// // Score dots
// mainGroup.selectAll(`.score-dot-group-${i}`)
// .data(d.scores)
// .enter()
// .append("circle")
// .attr("class", `score-dot`)
// .attr("cx", s => xScale(s.time))
// .attr("cy", s => yScoreScale(s.score));
// // Time blocks with full data for zoom tracking
// mainGroup.append("rect")
// .datum(d)
// .attr("class", `time-block`)
// .attr("x", xScale(d.startTime))
// .attr("y", plotHeight * 0.7)
// .attr("width", Math.max(2, xScale(d.endTime) - xScale(d.startTime)))
// .attr("height", blockHeight);
// // Conditional labels
// if (showLabels) {
// mainGroup.append("text")
// .datum(d)
// .attr("class", "block-start-label")
// .attr("x", xScale(d.startTime))
// .attr("y", plotHeight * 0.7 + blockHeight + 15)
// .attr("text-anchor", "start")
// .style("font-size", "10px")
// .text(d.startTime.toLocaleTimeString());
// mainGroup.append("text")
// .datum(d)
// .attr("class", "block-end-label")
// .attr("x", xScale(d.endTime))
// .attr("y", plotHeight * 0.7 + blockHeight + 15)
// .attr("text-anchor", "end")
// .style("font-size", "10px")
// .text(d.endTime.toLocaleTimeString());
// }
// });
// // Y-axis for scores
// const yAxis = d3.axisLeft(yScoreScale)
// .ticks(6)
// .tickFormat(d3.format(".4f"));
// mainGroup.append("g")
// .attr("class", "axis y-axis")
// .call(yAxis);
// // Y-axis label
// mainGroup.append("text")
// .attr("transform", "rotate(-90)")
// .attr("y", -40)
// .attr("x", -plotHeight / 2)
// .style("text-anchor", "middle")
// .text("Embed Score");
// // X-axis
// const xAxis = d3.axisBottom(xScale)
// .ticks(8)
// .tickFormat(d3.timeFormat("%H:%M:%S"));
// mainGroup.append("g")
// .attr("class", "axis x-axis")
// .attr("transform", `translate(0, ${plotHeight})`)
// .call(xAxis);
// }, [data, showLabels, zoomLevel]);
// // Zoom control handler
// const handleZoom = (factor) => {
// const currentZoom = zoomLevel;
// const newZoom = Math.max(1, Math.min(20, currentZoom * factor));
// setZoomLevel(newZoom);
// };
// // Reset zoom
// const handleResetZoom = () => {
// setZoomLevel(1);
// };
// return (
// <div className="timeline-container">
// <div className="timeline-controls">
// <button onClick={() => handleZoom(1.5)}>Zoom In</button>
// <button onClick={() => handleZoom(1 / 1.5)}>Zoom Out</button>
// <button onClick={handleResetZoom}>Reset Zoom</button>
// <button onClick={() => setShowLabels(!showLabels)}>
// {showLabels ? 'Hide Labels' : 'Show Labels'}
// </button>
// </div>
// <div ref={containerRef} className="svg-container">
// <svg ref={svgRef}></svg>
// </div>
// </div>
// );
// };
function onChartReady(echarts) {
// Chart is ready
}
const onEvents = { click: onChartClick };
window.chartRef2 = chartRef;
// --- Render ---
return (
<ReactECharts
ref={chartRef}
onChartReady={onChartReady}
onEvents={onEvents}
option={option}
style={{ width: "100%", height: "100%" }}
/>
);
});
export default EmbedTimeline;

View File

@@ -0,0 +1,79 @@
export default function StatusesDisplayHUD({ statusMessages }) {
const msg = {};
statusMessages.forEach((m) => {
let when_key = 'other'
if (m['task'] == 'SCHEDULED')
m['when'].forEach(( w ) => { msg[w] = 'Scheduled' })
else {
if ('when' in m)
when_key = m['when']
msg[when_key] = m['task']
}
});
return (
<div>
{Object.entries(msg).map(([when, messages], idx) => (
<StatusDisplay key={when} when={when} message={messages} />
))}
</div>
);
}
export function StatusDisplay({when, message }) {
let msg_show = ''
msg_show = when + ': ' + message
return (
<div
className="status-message"
style={{
color: "#fff",
background: "#23272f",
padding: "8px",
margin: "4px 0",
borderRadius: "4px",
minHeight: "20px",
}}
>
{msg_show}
</div>
);
}
// <div
// className="status-messages"
// style={{
// color: "#fff",
// background: "#23272f",
// padding: "8px",
// margin: "8px 0",
// borderRadius: "4px",
// minHeight: "40px",
// }}
// >
// {statusMessages.map((msg, idx) => (
// <div key={idx}>{msg}</div>
// ))}
// </div>

View File

@@ -0,0 +1,72 @@
import React, { useRef, useEffect, forwardRef, useImperativeHandle } from "react";
import videojs from "video.js";
import "video.js/dist/video-js.css";
const VideoPlayer = function VideoPlayer({videoRef, playerInstanceRef, setMarkerTimeFunc}) {
useEffect(() => {
// Prevent double init in StrictMode
if (!playerInstanceRef.current && videoRef.current) {
playerInstanceRef.current = videojs(videoRef.current, {
controls: true,
preload: "auto",
autoplay: true,
});
playerInstanceRef.current.on('timeupdate', async function (event) {
const res = await fetch('api/events/video_step', {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ timestamp: this.currentTime() }),
});
if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
const { path, timeoffset, do_update, absolute_time} = await res.json();
setMarkerTimeFunc(1000*absolute_time)
if (do_update) {
playerInstanceRef.current.src({ src: 'api/' + path, type: "video/mp4" });
// Seek after metadata is loaded
playerInstanceRef.current.on("loadedmetadata", () => {
playerInstanceRef.current.currentTime(timeoffset);
});
}
}
)
}
return () => {
if (playerInstanceRef.current) {
playerInstanceRef.current.dispose();
playerInstanceRef.current = null;
}
};
}, []);
return (
<div style={{ width: "100%", height: "100%" }}>
<div data-vjs-player style={{ width: "100%", height: "100%" }}>
<video
ref={videoRef}
className="video-js vjs-big-play-centered"
playsInline
style={{
width: "100%",
height: "100%",
backgroundColor: "black",
objectFit: "contain"
}}
/>
</div>
</div>
);
};
export default VideoPlayer;

View File

@@ -3,10 +3,11 @@
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color-scheme: light dark ;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;

View File

@@ -4,7 +4,7 @@ import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
// <StrictMode>
<App />
</StrictMode>,
// </StrictMode>,
)

View File

@@ -4,5 +4,13 @@ import react from '@vitejs/plugin-react-swc'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {'host':'0.0.0.0'}
server: {'host':'0.0.0.0',
'proxy':{
'/api': {
target: 'http://192.168.1.242:5003',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
}
}
}
})