timelineView = (initialTasks = []) => {
const width = 1200;
const height = 300;
const margin = {top: 50, right: 40, bottom: 30, left: 40};
let sharedTasks = [...initialTasks];
const container = document.createElement("div");
container.className = "timeline-container";
container.style.position = "relative";
const addTaskForm = document.createElement("div");
addTaskForm.innerHTML = `
<div style="display: flex; align-items: center; padding: 10px; background-color: #f4f4f4; border-bottom: 1px solid #ddd;">
<input
type="text"
id="task-name-input"
placeholder="Task"
style="flex-grow: 1; margin-right: 10px; padding: 5px; border: 1px solid #ddd; border-radius: 4px;"
>
<input
type="date"
id="task-date-input"
style="margin-right: 10px; padding: 5px; border: 1px solid #ddd; border-radius: 4px;"
>
<select
id="task-hours-input"
style="margin-right: 10px; padding: 5px; border: 1px solid #ddd; border-radius: 4px;"
>
<option value="1">1小时</option>
<option value="2">2小时</option>
<option value="3">3小时</option>
<option value="4">4小时</option>
<option value="5">5小时</option>
<option value="6">6小时</option>
<option value="7">7小时</option>
<option value="8">8小时</option>
</select>
<button
id="add-task-btn"
style="padding: 5px 10px; background-color: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer;"
>
添加任务
</button>
</div>
`;
container.appendChild(addTaskForm);
// Obtain the form element
const taskNameInput = addTaskForm.querySelector("#task-name-input");
const taskDateInput = addTaskForm.querySelector("#task-date-input");
const taskHoursInput = addTaskForm.querySelector("#task-hours-input");
const addTaskButton = addTaskForm.querySelector("#add-task-btn");
// Set the default date to today
taskDateInput.valueAsDate = new Date();
// Re-render the function
function rerenderTimeline() {
// Remove the previous SVG
const existingSvg = container.querySelector('svg');
if (existingSvg) {
container.removeChild(existingSvg);
}
// recreate and add SVG
const newSvg = createTimelineSvg(sharedTasks);
container.appendChild(newSvg);
}
// The function for adding tasks
function addNewTask() {
const taskName = taskNameInput.value.trim();
const taskDate = new Date(taskDateInput.value);
const taskHours = parseInt(taskHoursInput.value, 10);
// basic authentication
if (!taskName) {
alert("Please enter task name here");
return;
}
// 生Form a new task ID (simply use the current timestamp here)
const newTaskId = Date.now();
// Create a new task
const newTask = {
id: newTaskId,
text: taskName,
dueDate: taskDate,
estimatedHours: taskHours,
x: 0.5,
y: 0.5
};
// Add the new task to sharedTasks
sharedTasks.push(newTask);
// Re-render the timeline
rerenderTimeline();
// Empty the input box
taskNameInput.value = "";
taskDateInput.valueAsDate = new Date();
taskHoursInput.value = "1";
}
// Bind and add the task button event
addTaskButton.addEventListener("click", addNewTask);
// Support adding tasks by pressing the Enter key
taskNameInput.addEventListener("keyup", (event) => {
if (event.key === "Enter") {
addNewTask();
}
});
// Create a timeline SVG function
function createTimelineSvg(tasks) {
// Sort tasks
const sortedTasks = [...tasks].sort((a, b) => a.dueDate - b.dueDate);
// Check if there are any tasks
if (sortedTasks.length === 0) {
const emptyMessage = document.createElement("div");
emptyMessage.textContent = "No tasks for now";
emptyMessage.style.textAlign = "center";
emptyMessage.style.padding = "40px";
emptyMessage.style.color = "#666";
return emptyMessage;
}
// Create SVG
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto;");
// Calculate the date range and add fillers
const minDate = d3.min(sortedTasks, d => d.dueDate) || new Date();
let maxDate = d3.max(sortedTasks, d => d.dueDate) || new Date(new Date().setDate(new Date().getDate() + 14));
// Ensure a range of at least 14 days
if (maxDate - minDate < 1000 * 60 * 60 * 24 * 14) {
maxDate = new Date(minDate);
maxDate.setDate(minDate.getDate() + 14);
}
// Add 2 days of filling at each end
const startDate = new Date(minDate);
startDate.setDate(startDate.getDate() - 2);
const endDate = new Date(maxDate);
endDate.setDate(endDate.getDate() + 2);
// Create a time scale
const timeScale = d3.scaleTime()
.domain([startDate, endDate])
.range([margin.left, width - margin.right])
.nice(d3.timeDay, 1);
// Create a vertical position scale
const yScale = d3.scaleLinear()
.domain([0, 1])
.range([margin.top + 20, height - margin.bottom - 20]);
// Create a date slot (adsorption target)
const daySlots = d3.timeDay.range(
d3.timeDay.floor(startDate),
d3.timeDay.offset(d3.timeDay.ceil(endDate), 1)
);
// An auxiliary function for finding the most recent date slot
function findClosestDaySlot(date) {
let closestDay = daySlots[0];
let minDistance = Math.abs(date - closestDay);
daySlots.forEach(day => {
const distance = Math.abs(date - day);
if (distance < minDistance) {
minDistance = distance;
closestDay = day;
}
});
return closestDay;
}
// Check if there are any tasks
const drag = d3.drag()
.on("start", function(event, d) {
if (event.sourceEvent) event.sourceEvent.stopPropagation();
d3.select(this).raise();
d3.select(this).style("cursor", "grabbing");
})
.on("drag", function(event, d) {
if (event.sourceEvent) event.sourceEvent.stopPropagation();
// Obtain the current position
const dateAtPosition = timeScale.invert(event.x);
const snappedDate = findClosestDaySlot(dateAtPosition);
// Calculate the vertical position (range 0-1)
const yPosition = Math.max(0, Math.min(1,
(event.y - margin.top) / (height - margin.top - margin.bottom)
));
// Update the visual position
d3.select(this).attr("transform",
`translate(${timeScale(snappedDate)},${yScale(yPosition)})`
);
// Update date text
d3.select(this).select(".date-text")
.text(d3.timeFormat("%b %d")(snappedDate));
// Store the current Y position
d3.select(this).attr("data-y", yScale(yPosition));
})
.on("end", function(event, d) {
if (event.sourceEvent) event.sourceEvent.stopPropagation();
d3.select(this).style("cursor", "grab");
// Obtain the final position
const dateAtPosition = timeScale.invert(event.x);
const snappedDate = findClosestDaySlot(dateAtPosition);
// Calculate the vertical position (range 0-1)
const yPosition = Math.max(0, Math.min(1,
(event.y - margin.top) / (height - margin.top - margin.bottom)
));
// Update data
const taskId = +d3.select(this).attr("data-id");
const taskIndex = sharedTasks.findIndex(t => t.id === taskId);
if (taskIndex !== -1) {
sharedTasks[taskIndex] = {
...sharedTasks[taskIndex],
dueDate: snappedDate,
timelineY: yPosition
};
}
});
// Create a task container
const tasksContainer = svg.append("g")
.attr("class", "tasks-container");
// Create task
sortedTasks.forEach(task => {
// Use the timelineY attribute of the task or set the default value
const yPos = task.timelineY !== undefined ?
yScale(task.timelineY) : yScale(0.2 + Math.random() * 0.6);
const taskGroup = tasksContainer.append("g")
.attr("class", "task")
.attr("data-id", task.id)
.attr("data-y", yPos)
.attr("transform", `translate(${timeScale(task.dueDate)},${yPos})`)
.attr("cursor", "grab")
.call(drag);
// Working hours scaling
const hoursScale = d3.scaleLinear()
.domain([1, 8])
.range([100, 250])
.clamp(true);
// TaskBarBackground
taskGroup.append("rect")
.attr("class", "task-bg")
.attr("x", -5)
.attr("y", -15)
.attr("width", hoursScale(task.estimatedHours))
.attr("height", 30)
.attr("fill", d3.color(getTaskColor(task.x, task.y)).copy({opacity: 0.15}))
.attr("stroke", getTaskColor(task.x, task.y))
.attr("stroke-width", 2)
.attr("rx", 5)
.attr("ry", 5)
.attr("filter", "url(#drop-shadow)");
// color bar
taskGroup.append("rect")
.attr("class", "task-bar")
.attr("x", -5)
.attr("y", -15)
.attr("width", 5)
.attr("height", 30)
.attr("fill", getTaskColor(task.x, task.y))
.attr("rx", 2)
.attr("ry", 2);
// Priority indicator
taskGroup.append("circle")
.attr("class", "priority-indicator")
.attr("r", 6)
.attr("cx", 6)
.attr("cy", 0)
.attr("fill", getTaskColor(task.x, task.y));
// Objective text
taskGroup.append("text")
.attr("x", 18)
.attr("y", 4)
.attr("font-size", "12px")
.attr("font-weight", "500")
.text(task.text);
// date literal
taskGroup.append("text")
.attr("class", "date-text")
.attr("x", 18)
.attr("y", -8)
.attr("font-size", "9px")
.attr("fill", "#666")
.text(d3.timeFormat("%b %d")(task.dueDate));
// Working hour text
taskGroup.append("text")
.attr("x", hoursScale(task.estimatedHours) - 10)
.attr("y", 4)
.attr("font-size", "10px")
.attr("text-anchor", "end")
.attr("fill", "#666")
.text(`${task.estimatedHours}h`);
});
// title
svg.append("text")
.attr("x", width / 2)
.attr("y", 20)
.attr("text-anchor", "middle")
.attr("font-size", "16px")
.attr("font-weight", "bold")
.text("Task timeline (can be freely dragged and repositioned)");
return svg.node();
}
// 初始渲染
const initialSvg = createTimelineSvg(sharedTasks);
container.appendChild(initialSvg);
return container;
}