Files

225 lines
8.1 KiB
JavaScript

/**
* _ ___ Another
* / |/ (_)______ __ _____
* / / / __(_-</ // (_-<
* /_/|_/_/\__/___/\_, /___/
* /___/
* production !
*
* Licensed under the MIT License:
* This code is free to use and modify,
* as long as the copyright notice and license are kept.
*/
class BZgantt extends Buildoz {
constructor(){
super()
this.defaultAttrs = { }
}
async connectedCallback(){
await customElements.whenDefined('bz-graflow')
super.connectedCallback()
this.rowHeight = parseFloat(getComputedStyle(this).getPropertyValue('--row')) || 30
this.ul = document.createElement('ul')
this.ul.classList.add('tasks')
this.append(this.ul)
this.graflow = document.createElement('bz-graflow')
this._syncAttributesToGraflow()
this.append(this.graflow)
this.fireEvent('domConnected', { gantt: this })
this.dayWidth = this.getBZAttribute('daywidth') || 20
if(this.getBZAttribute('nodesfile')) this.nodesfile = this.getBZAttribute('nodesfile')
else this.initGantt()
const dataSrc = this.getBZAttribute('src')
if(dataSrc) this.loadData(dataSrc)
else this.gantt = { "tasks": [] }
}
_syncAttributesToGraflow(){
for(const attr of this.attributes){
if(attr.name === 'flow') continue
this.graflow.setAttribute(attr.name, attr.value)
}
}
async loadData(dataSrc){
let buf, ganttObj
if(dataSrc instanceof Blob){
buf = await dataSrc.text()
try{
ganttObj = JSON.parse(buf)
} catch(err){
this.error('Could not parse gantt data JSON!?', err)
return
}
} else if(typeof dataSrc == 'string') {
const url = dataSrc
const fetchUrl = (typeof url === 'string' && !url.startsWith('blob:') && !url.startsWith('data:'))
? (url + '?' + crypto.randomUUID())
: url
const res = await fetch(fetchUrl)
buf = await res.text()
try{
ganttObj = JSON.parse(buf)
} catch(err){
this.error('Could not parse gantt data JSON!?', err)
return
}
} else if(typeof dataSrc == 'object') {
ganttObj = structuredClone(dataSrc)
}
if(!ganttObj.nodesFile){
if(this.getBZAttribute('nodesfile')) ganttObj.nodesFile = this.getBZAttribute('nodesfile')
else {
this.error('No nodesFile in JSON!?')
return
}
}
this.tasks = ganttObj.tasks
this.reorder_tasks()
await this.graflow.loadNodes(ganttObj.nodesFile)
this.refresh()
this.fireEvent('ganttDataLoaded', {
component: this,
})
}
reorder_tasks(){
const byId = new Map(this.tasks.map(t => [t.id, t]))
const children = new Map(this.tasks.map(t => [t.id, []]))
for(const task of this.tasks){
for(const dep of task.dependsOn ?? []){
if(children.has(dep)) children.get(dep).push(task)
}
}
// Subtree weight: number of (unique) descendants of a task
const descendants = new Map()
const countDescendants = (task, seen) => {
for(const child of children.get(task.id)){
if(seen.has(child.id)) continue
seen.add(child.id)
countDescendants(child, seen)
}
return(seen.size)
}
for(const task of this.tasks) descendants.set(task.id, countDescendants(task, new Set()))
const byWeight = (a, b) => descendants.get(a.id) - descendants.get(b.id)
// Depth-first walk, a task is placed once all its dependencies are placed
const ordered = []
const placed = new Set()
const ready = task => (task.dependsOn ?? []).every(dep => placed.has(dep) || !byId.has(dep))
const visit = task => {
if(placed.has(task.id) || !ready(task)) return
placed.add(task.id)
ordered.push(task)
for(const child of [...children.get(task.id)].sort(byWeight)) visit(child)
}
const roots = this.tasks.filter(t => !(t.dependsOn?.length)).sort(byWeight)
for(const root of roots) visit(root)
// Leftovers (circular dependencies) keep their original order
for(const task of this.tasks) if(!placed.has(task.id)) ordered.push(task)
this.tasks = ordered
}
getDayIndex(firstDate, date){
return Math.ceil((date-firstDate)/86400000)
}
buildFlow(){
let firstDate = new Date(this.tasks[0].start)
let lastDate = new Date(this.tasks[0].end)
for(const task of this.tasks){
const startDate = new Date(task.start)
const endDate = new Date(task.end)
if(startDate < firstDate) firstDate = startDate
if(endDate > lastDate) lastDate = endDate
}
const flow = { "nodes": [], "links": [] }
let curY = 0
const endIdx = this.getDayIndex(firstDate, lastDate)
for(const task of this.tasks){
const startIdx = this.getDayIndex(firstDate, new Date(task.start))
const taskEndIdx = this.getDayIndex(firstDate, new Date(task.end))
const markup = {}
for(const [key, value] of Object.entries(task)){
if(value == null || typeof value === 'object') continue
markup[`task.${key}`] = value
}
if(!markup['task.color']) markup['task.color'] = '#CCC'
markup.width = (startIdx<taskEndIdx) ? (taskEndIdx-startIdx) * this.dayWidth : this.dayWidth
flow.nodes.push({
"nodeType": "bar",
"id": task.id,
"coords": {
"x": startIdx * this.dayWidth,
"y": curY,
},
"markup": markup
})
for(const dep of task.dependsOn ?? []){
flow.links.push({ "from": [dep, "out1"], "to": [task.id, "inp1"] })
}
curY += this.rowHeight
}
return([flow, firstDate, endIdx])
}
refresh(){
this.ul.innerHTML = ''
this.headerLi = document.createElement('li')
this.headerLi.classList.add('header')
this.ul.appendChild(this.headerLi)
for(const task of this.tasks){
const li = document.createElement('li')
li.textContent = task.name
this.ul.appendChild(li)
}
// Publish the widest label width, used by CSS to place the graflow
const range = document.createRange()
let maxWidth = 0
for(const li of this.ul.children){
range.selectNodeContents(li)
maxWidth = Math.max(maxWidth, range.getBoundingClientRect().width)
}
this.style.setProperty('--tasks-width', Math.ceil(maxWidth) + 'px')
const [flow, firstDate, endIdx] = this.buildFlow()
let dayDate = new Date(firstDate)
for(let idx=0; idx<=endIdx; idx++){
const dayDiv = document.createElement('div')
dayDiv.classList.add('day')
dayDiv.style.left = `calc(var(--chart-left) + ${idx * this.dayWidth}px)`
dayDiv.style.width = this.dayWidth + 'px'
dayDiv.innerHTML = '&nbsp;' + dayDate.getDate()
if(dayDate.toISOString().substring(0,10) == (new Date()).toISOString().substring(0,10)) dayDiv.classList.add('today')
dayDiv.setAttribute('title', new Intl.DateTimeFormat('fr-FR', {
timeZone: 'Europe/Paris',
day: '2-digit',
month: '2-digit',
year: '2-digit'
}).format(dayDate))
dayDate.setDate(dayDate.getDate() + 1)
this.headerLi.appendChild(dayDiv)
}
this.graflow.loadFlow({
"nodesFile": this.nodesfile,
"flow": flow
})
this.graflow.refresh()
}
}
Buildoz.define('gantt', BZgantt)