2019-03-21 09:57:27 +01:00
|
|
|
import { Cycle, Colors, Dates, isEmpty } from '../utils.js'
|
|
|
|
import { Capabilities } from '../capabilities.js'
|
2019-06-24 10:50:51 +02:00
|
|
|
import { BitmapLabeledGraphics, FontInfo } from './labeledgraphics.js'
|
2019-03-21 09:57:27 +01:00
|
|
|
|
|
|
|
export class Ticks {
|
|
|
|
get reservedPrefixes() {
|
|
|
|
return ['decade', 'year', 'month', 'day', 'hour', 'minute', 'second']
|
|
|
|
}
|
|
|
|
|
2019-06-21 12:28:52 +02:00
|
|
|
static get largeTickSize() {
|
|
|
|
return 4.2
|
|
|
|
}
|
|
|
|
|
2019-03-21 09:57:27 +01:00
|
|
|
get minWidth() {
|
|
|
|
return 10
|
|
|
|
}
|
|
|
|
|
|
|
|
format(available) {
|
|
|
|
return { year: '2-digit', timeZone: 'UTC' }
|
|
|
|
}
|
|
|
|
|
|
|
|
get minLabelWidth() {
|
|
|
|
return 44
|
|
|
|
}
|
|
|
|
|
|
|
|
get formatKey() {
|
|
|
|
return this.key
|
|
|
|
}
|
|
|
|
|
|
|
|
dateKey(date) {
|
|
|
|
return this.key + date.getFullYear()
|
|
|
|
}
|
|
|
|
|
|
|
|
*iter(start, end) {
|
|
|
|
let date = this.iterStart(start)
|
|
|
|
while (date <= end) {
|
|
|
|
yield date
|
|
|
|
date = this.next(date)
|
|
|
|
}
|
|
|
|
yield date
|
|
|
|
}
|
|
|
|
|
|
|
|
*iterRanges(range) {
|
|
|
|
for (let date of this.iter(range.start, range.end)) {
|
|
|
|
let next = this.next(date)
|
|
|
|
yield { start: date, end: next }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
selectedRange(timeline, info) {
|
|
|
|
let first = null
|
|
|
|
let last = null
|
|
|
|
let visibleFirst = null
|
|
|
|
let visibleLast = null
|
|
|
|
let units = 0
|
|
|
|
for (let { start, end } of this.iterRanges(info)) {
|
|
|
|
if (timeline.visibleRange(start, end)) {
|
|
|
|
if (first == null) {
|
|
|
|
first = start
|
|
|
|
}
|
|
|
|
last = end
|
|
|
|
}
|
|
|
|
if (timeline.visibleDate(start) && timeline.visibleDate(end)) {
|
|
|
|
units += 1
|
|
|
|
if (visibleFirst == null) {
|
|
|
|
visibleFirst = start
|
|
|
|
}
|
|
|
|
visibleLast = end
|
|
|
|
}
|
|
|
|
}
|
2019-07-18 12:26:39 +02:00
|
|
|
if (first == null) return info
|
|
|
|
return {
|
|
|
|
start: first,
|
|
|
|
end: last,
|
|
|
|
visibleStart: visibleFirst,
|
|
|
|
visibleEnd: visibleLast,
|
|
|
|
units: units
|
|
|
|
}
|
2019-03-21 09:57:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
drawTick(timeline, x, y, date) {
|
|
|
|
let visible = date > timeline.start && date < timeline.end
|
2019-07-18 12:26:39 +02:00
|
|
|
if (!visible) return false
|
2019-03-21 09:57:27 +01:00
|
|
|
timeline.drawTick(x)
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
toLocaleString(date, format) {
|
|
|
|
return date.toLocaleDateString('de', format)
|
|
|
|
}
|
|
|
|
|
2019-07-18 12:26:39 +02:00
|
|
|
draw(
|
|
|
|
timeline,
|
|
|
|
range,
|
|
|
|
width,
|
|
|
|
height,
|
|
|
|
available,
|
|
|
|
format,
|
|
|
|
nextFormat,
|
|
|
|
level,
|
|
|
|
extraTicks = false
|
|
|
|
) {
|
2019-03-21 09:57:27 +01:00
|
|
|
let first = null
|
|
|
|
let last = null
|
2019-07-18 12:26:39 +02:00
|
|
|
let keyedFormat = format ? format[this.formatKey] : null
|
|
|
|
let keyedNextFormat = nextFormat ? nextFormat[this.formatKey] : null
|
|
|
|
let redundant = nextFormat ? this.formatKey in nextFormat : false
|
|
|
|
let fullyRedundant =
|
|
|
|
keyedFormat != null && keyedFormat == keyedNextFormat
|
2019-03-21 09:57:27 +01:00
|
|
|
let y = timeline.getY()
|
|
|
|
for (let { start, end } of this.iterRanges(range)) {
|
|
|
|
let x = timeline.toX(start)
|
|
|
|
let xx = x
|
|
|
|
let yy = y + timeline.tickLabelOffset(-1)
|
|
|
|
if (this.drawTick(timeline, x, y, start) && format) {
|
|
|
|
let key = this.dateKey(start)
|
|
|
|
let text = this.toLocaleString(start, format)
|
|
|
|
let align = 'center'
|
|
|
|
if (nextFormat) {
|
|
|
|
yy = y + timeline.tickLabelOffset(-1, 1)
|
|
|
|
align = 'left'
|
2019-06-21 12:28:52 +02:00
|
|
|
timeline.drawTick(x, Ticks.largeTickSize)
|
2019-03-21 09:57:27 +01:00
|
|
|
let nextX = timeline.toX(end) - 100
|
|
|
|
if (x < 0 && nextX > -100 && !redundant) {
|
|
|
|
xx = Math.min(4, nextX)
|
2019-07-18 12:26:39 +02:00
|
|
|
} else {
|
2019-03-21 09:57:27 +01:00
|
|
|
xx -= 2
|
|
|
|
}
|
2019-07-18 12:26:39 +02:00
|
|
|
} else if (level > 0) {
|
2019-03-21 09:57:27 +01:00
|
|
|
xx = x + available / 2
|
|
|
|
}
|
2019-07-18 12:26:39 +02:00
|
|
|
|
2019-03-21 09:57:27 +01:00
|
|
|
if (!fullyRedundant) {
|
2019-07-18 12:26:39 +02:00
|
|
|
timeline.ensureLabel(
|
|
|
|
key,
|
|
|
|
text,
|
2019-03-21 09:57:27 +01:00
|
|
|
{ x: xx, y: yy, align },
|
2019-07-18 12:26:39 +02:00
|
|
|
FontInfo.small
|
|
|
|
)
|
2019-03-21 09:57:27 +01:00
|
|
|
}
|
2019-07-18 12:26:39 +02:00
|
|
|
if (extraTicks) timeline.drawTick(x, -level)
|
2019-03-21 09:57:27 +01:00
|
|
|
}
|
|
|
|
if (timeline.visibleRange(start, end)) {
|
2019-07-18 12:26:39 +02:00
|
|
|
if (first == null) first = start
|
2019-03-21 09:57:27 +01:00
|
|
|
last = end
|
|
|
|
}
|
|
|
|
}
|
2019-07-18 12:26:39 +02:00
|
|
|
if (first == null) return null
|
2019-03-21 09:57:27 +01:00
|
|
|
return { start: first, end: last }
|
|
|
|
}
|
2019-06-21 12:28:52 +02:00
|
|
|
|
|
|
|
drawExtra(timeline, range, size) {
|
|
|
|
for (let { start } of this.iterRanges(range)) {
|
|
|
|
if (timeline.visibleDate(start)) {
|
|
|
|
let x = timeline.toX(start)
|
|
|
|
timeline.drawTick(x, -size)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2019-03-21 09:57:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
export class DecadeTicks extends Ticks {
|
|
|
|
get milliseconds() {
|
|
|
|
return 10 * 365 * 24 * 60 * 60 * 1000
|
|
|
|
}
|
|
|
|
|
|
|
|
format(available) {
|
|
|
|
return { year: 'numeric', timeZone: 'UTC' }
|
|
|
|
}
|
|
|
|
|
|
|
|
selection(timeline, dates, selected) {
|
|
|
|
return dates
|
|
|
|
}
|
|
|
|
|
|
|
|
get key() {
|
|
|
|
return 'decade'
|
|
|
|
}
|
|
|
|
|
|
|
|
get formatKey() {
|
|
|
|
return 'year'
|
|
|
|
}
|
|
|
|
|
|
|
|
iterStart(start) {
|
|
|
|
let modulo = start.getFullYear() % 10
|
|
|
|
let year = start.getFullYear() - modulo
|
|
|
|
return Dates.create(year, 0, 1)
|
|
|
|
}
|
|
|
|
|
|
|
|
next(decade) {
|
|
|
|
return Dates.nextYear(decade, 10)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export class YearTicks extends Ticks {
|
|
|
|
get milliseconds() {
|
|
|
|
return 365 * 24 * 60 * 60 * 1000
|
|
|
|
}
|
|
|
|
|
|
|
|
format(available) {
|
2019-07-18 12:26:39 +02:00
|
|
|
if (available < 44) return { year: '2-digit', timeZone: 'UTC' }
|
2019-03-21 09:57:27 +01:00
|
|
|
return { year: 'numeric', timeZone: 'UTC' }
|
|
|
|
}
|
|
|
|
|
|
|
|
get minLabelWidth() {
|
|
|
|
return 22
|
|
|
|
}
|
|
|
|
|
|
|
|
get key() {
|
|
|
|
return 'year'
|
|
|
|
}
|
|
|
|
|
|
|
|
iterStart(start) {
|
|
|
|
return Dates.create(start.getFullYear(), 0, 1)
|
|
|
|
}
|
|
|
|
|
|
|
|
next(year) {
|
|
|
|
return Dates.nextYear(year)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export class MonthTicks extends Ticks {
|
|
|
|
get milliseconds() {
|
|
|
|
return (365 / 12) * 24 * 60 * 60 * 1000
|
|
|
|
}
|
|
|
|
|
|
|
|
format(available) {
|
|
|
|
let format = { month: 'narrow', timeZone: 'UTC' }
|
2019-07-18 12:26:39 +02:00
|
|
|
if (available > 44) format.month = 'short'
|
|
|
|
if (available > 66) format.year = '2-digit'
|
2019-03-21 09:57:27 +01:00
|
|
|
if (available > 100) {
|
|
|
|
format.month = 'long'
|
|
|
|
format.year = 'numeric'
|
|
|
|
}
|
|
|
|
return format
|
|
|
|
}
|
|
|
|
|
|
|
|
get minLabelWidth() {
|
|
|
|
return 32
|
|
|
|
}
|
|
|
|
|
|
|
|
get key() {
|
|
|
|
return 'month'
|
|
|
|
}
|
|
|
|
|
|
|
|
dateKey(date) {
|
|
|
|
return this.key + date.getFullYear() + date.getMonth()
|
|
|
|
}
|
|
|
|
|
|
|
|
iterStart(start) {
|
|
|
|
return Dates.create(start.getFullYear(), start.getMonth(), 1)
|
|
|
|
}
|
|
|
|
|
|
|
|
next(month) {
|
|
|
|
return Dates.nextMonth(month)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export class DayTicks extends Ticks {
|
|
|
|
get milliseconds() {
|
|
|
|
return 24 * 60 * 60 * 1000
|
|
|
|
}
|
|
|
|
|
|
|
|
format(available) {
|
|
|
|
let format = { day: 'numeric', timeZone: 'UTC' }
|
2019-07-18 12:26:39 +02:00
|
|
|
if (available > 44) format.month = 'short'
|
2019-03-21 09:57:27 +01:00
|
|
|
if (available > 100) {
|
|
|
|
format.month = 'long'
|
|
|
|
format.year = '2-digit'
|
|
|
|
}
|
|
|
|
if (available > 150) {
|
|
|
|
format.weekday = 'short'
|
|
|
|
}
|
|
|
|
if (available > 200) {
|
|
|
|
format.year = 'numeric'
|
|
|
|
format.weekday = 'long'
|
|
|
|
}
|
|
|
|
return format
|
|
|
|
}
|
|
|
|
|
|
|
|
get key() {
|
|
|
|
return 'day'
|
|
|
|
}
|
|
|
|
|
|
|
|
dateKey(date) {
|
|
|
|
return this.key + date.getFullYear() + date.getMonth() + date.getDate()
|
|
|
|
}
|
|
|
|
|
|
|
|
iterStart(start) {
|
2019-07-18 12:26:39 +02:00
|
|
|
return Dates.create(
|
|
|
|
start.getFullYear(),
|
|
|
|
start.getMonth(),
|
|
|
|
start.getDate()
|
|
|
|
)
|
2019-03-21 09:57:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
next(date) {
|
|
|
|
return Dates.nextDay(date)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export class HourTicks extends Ticks {
|
|
|
|
get milliseconds() {
|
|
|
|
return 60 * 60 * 1000
|
|
|
|
}
|
|
|
|
|
|
|
|
format(available) {
|
|
|
|
let format = {}
|
|
|
|
if (available > 44) {
|
|
|
|
format.hour = '2-digit'
|
|
|
|
}
|
|
|
|
if (available > 100) {
|
|
|
|
format.day = '2-digit'
|
|
|
|
format.month = '2-digit'
|
|
|
|
format.year = '2-digit'
|
|
|
|
}
|
|
|
|
if (available > 150) {
|
|
|
|
format.weekday = 'short'
|
|
|
|
format.month = 'short'
|
|
|
|
}
|
|
|
|
if (available > 200) {
|
|
|
|
format.day = 'numeric'
|
|
|
|
format.year = 'numeric'
|
|
|
|
format.month = 'long'
|
|
|
|
format.weekday = 'long'
|
|
|
|
}
|
|
|
|
return format
|
|
|
|
}
|
|
|
|
|
|
|
|
get key() {
|
|
|
|
return 'hour'
|
|
|
|
}
|
|
|
|
|
|
|
|
dateKey(date) {
|
2019-07-18 12:26:39 +02:00
|
|
|
return (
|
|
|
|
this.key +
|
|
|
|
date.getFullYear() +
|
|
|
|
date.getMonth() +
|
|
|
|
date.getDate() +
|
|
|
|
date.getHours()
|
|
|
|
)
|
2019-03-21 09:57:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
iterStart(start) {
|
2019-07-18 12:26:39 +02:00
|
|
|
return Dates.create(
|
|
|
|
start.getFullYear(),
|
2019-03-21 09:57:27 +01:00
|
|
|
start.getMonth(),
|
|
|
|
start.getDate(),
|
2019-07-18 12:26:39 +02:00
|
|
|
start.getHours()
|
|
|
|
)
|
2019-03-21 09:57:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
next(date) {
|
|
|
|
return Dates.nextHour(date)
|
|
|
|
}
|
|
|
|
|
|
|
|
toLocaleString(date, format) {
|
|
|
|
return date.toLocaleTimeString('de', format)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export class MinuteTicks extends Ticks {
|
|
|
|
get milliseconds() {
|
|
|
|
return 60 * 1000
|
|
|
|
}
|
|
|
|
|
|
|
|
format(available) {
|
|
|
|
let format = { minute: 'numeric', timeZone: 'UTC' }
|
|
|
|
if (available > 44) {
|
|
|
|
format.hour = 'numeric'
|
|
|
|
format.minute = 'numeric'
|
|
|
|
}
|
|
|
|
if (available > 100) {
|
|
|
|
format.month = 'short'
|
|
|
|
format.year = '2-digit'
|
|
|
|
}
|
|
|
|
if (available > 150) {
|
|
|
|
format.weekday = 'short'
|
|
|
|
}
|
|
|
|
if (available > 200) {
|
|
|
|
format.year = 'numeric'
|
|
|
|
format.weekday = 'long'
|
|
|
|
}
|
|
|
|
return format
|
|
|
|
}
|
|
|
|
|
|
|
|
get key() {
|
|
|
|
return 'minute'
|
|
|
|
}
|
|
|
|
|
|
|
|
dateKey(date) {
|
2019-07-18 12:26:39 +02:00
|
|
|
return (
|
|
|
|
this.key +
|
|
|
|
date.getFullYear() +
|
|
|
|
date.getMonth() +
|
|
|
|
date.getDate() +
|
|
|
|
date.getHours() +
|
|
|
|
date.getMinutes()
|
|
|
|
)
|
2019-03-21 09:57:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
iterStart(start) {
|
2019-07-18 12:26:39 +02:00
|
|
|
return Dates.create(
|
|
|
|
start.getFullYear(),
|
2019-03-21 09:57:27 +01:00
|
|
|
start.getMonth(),
|
|
|
|
start.getDate(),
|
|
|
|
start.getHours(),
|
2019-07-18 12:26:39 +02:00
|
|
|
start.getMinutes()
|
|
|
|
)
|
2019-03-21 09:57:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
next(date) {
|
|
|
|
return Dates.nextMinute(date)
|
|
|
|
}
|
|
|
|
|
|
|
|
toLocaleString(date, format) {
|
|
|
|
return date.toLocaleTimeString('de', format)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export class TimeTicks {
|
|
|
|
constructor(...ticks) {
|
|
|
|
this.ticks = ticks
|
|
|
|
}
|
|
|
|
|
|
|
|
selectedRange(timeline) {
|
|
|
|
let info = { start: timeline.start, end: timeline.end, units: 0 }
|
|
|
|
for (let ticks of this.ticks) {
|
|
|
|
info = ticks.selectedRange(timeline, info)
|
|
|
|
if (info.units > 1) {
|
|
|
|
timeline.selection = [info.visibleStart, info.visibleEnd]
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
timeline.selection = [info.start, info.end]
|
|
|
|
}
|
|
|
|
|
|
|
|
draw(timeline, width, height) {
|
|
|
|
let range = timeline
|
|
|
|
let start = timeline.toX(range.start)
|
|
|
|
let end = timeline.toX(range.end)
|
|
|
|
let size = end - start
|
|
|
|
let duration = timeline.end - timeline.start
|
|
|
|
let formats = new Map()
|
|
|
|
let nextFormats = new Map()
|
|
|
|
let availables = new Map()
|
|
|
|
let previous = null
|
|
|
|
let visible = []
|
|
|
|
for (let ticks of this.ticks) {
|
|
|
|
let amount = ticks.milliseconds / duration
|
|
|
|
let available = amount * size
|
|
|
|
availables.set(ticks, available)
|
2019-07-18 12:26:39 +02:00
|
|
|
if (available < ticks.minWidth) break
|
|
|
|
formats.set(
|
|
|
|
ticks,
|
|
|
|
available < ticks.minLabelWidth ? null : ticks.format(available)
|
|
|
|
)
|
2019-03-21 09:57:27 +01:00
|
|
|
nextFormats.set(previous, formats.get(ticks))
|
|
|
|
previous = ticks
|
|
|
|
visible.push(ticks)
|
|
|
|
}
|
|
|
|
let level = 0
|
2019-06-21 12:28:52 +02:00
|
|
|
let ranges = []
|
2019-03-21 09:57:27 +01:00
|
|
|
for (let ticks of visible) {
|
2019-07-18 12:26:39 +02:00
|
|
|
if (range == null) continue
|
|
|
|
range = ticks.draw(
|
|
|
|
timeline,
|
|
|
|
range,
|
|
|
|
width,
|
|
|
|
height,
|
2019-03-21 09:57:27 +01:00
|
|
|
availables.get(ticks),
|
|
|
|
formats.get(ticks),
|
2019-07-18 12:26:39 +02:00
|
|
|
nextFormats.get(ticks),
|
|
|
|
level
|
|
|
|
)
|
2019-06-21 12:28:52 +02:00
|
|
|
if (range) {
|
2019-07-18 12:26:39 +02:00
|
|
|
ranges.push({ ticks, range })
|
2019-06-21 12:28:52 +02:00
|
|
|
}
|
2019-03-21 09:57:27 +01:00
|
|
|
level += 1
|
|
|
|
}
|
2019-06-21 12:28:52 +02:00
|
|
|
|
|
|
|
let extraLevel = ranges.length - 1
|
|
|
|
let extraStart = extraLevel
|
2019-07-18 12:26:39 +02:00
|
|
|
for (let { ticks, range } of ranges) {
|
2019-06-21 12:28:52 +02:00
|
|
|
ticks.drawExtra(timeline, range, extraLevel)
|
|
|
|
extraLevel -= 1
|
|
|
|
if (extraLevel <= 0) {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
timeline.drawTick(start, Ticks.largeTickSize)
|
|
|
|
timeline.drawTick(start, -extraStart)
|
|
|
|
timeline.drawTick(end, Ticks.largeTickSize)
|
|
|
|
timeline.drawTick(end, -extraStart)
|
2019-03-21 09:57:27 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export class ColorRanges {
|
|
|
|
constructor(label, color, ranges) {
|
|
|
|
this.label = label
|
|
|
|
this.color = color
|
|
|
|
this.ranges = ranges
|
|
|
|
}
|
|
|
|
|
|
|
|
draw(timeline, width, height, size = 12) {
|
|
|
|
let minimum = 1 / Capabilities.devicePixelRatio
|
|
|
|
let h2 = size
|
|
|
|
timeline.lineStyle(size, this.color)
|
|
|
|
for (let range of this.ranges) {
|
|
|
|
if (range.to === null) {
|
|
|
|
range.to = Dates.nextDay(range.from)
|
|
|
|
}
|
|
|
|
let start = timeline.toX(range.from)
|
|
|
|
let end = timeline.toX(range.to)
|
|
|
|
if (end < start + minimum) {
|
|
|
|
end = start + minimum
|
|
|
|
}
|
|
|
|
timeline.moveTo(start, h2)
|
|
|
|
timeline.lineTo(end, h2)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export default class Timeline extends BitmapLabeledGraphics {
|
2019-07-18 12:26:39 +02:00
|
|
|
constructor(
|
|
|
|
width,
|
|
|
|
height,
|
|
|
|
{
|
|
|
|
ticks = null,
|
|
|
|
baseLine = 0.5,
|
|
|
|
showRange = true,
|
|
|
|
throwDamping = 0.95
|
|
|
|
} = {}
|
|
|
|
) {
|
2019-03-21 09:57:27 +01:00
|
|
|
super()
|
|
|
|
this.wantedWidth = width
|
|
|
|
this.wantedHeight = height
|
|
|
|
this.extraLeft = 0
|
|
|
|
this.extraRight = 0
|
|
|
|
this.inset = 5
|
|
|
|
this.showRange = showRange
|
|
|
|
this.baseLine = baseLine
|
|
|
|
this.tickHeight = 4
|
|
|
|
this.zoom = 1
|
|
|
|
this.minZoom = 1
|
|
|
|
this.maxZoom = 12000
|
|
|
|
this.scroll = 0
|
|
|
|
this.deltas = []
|
|
|
|
this.labelDates = []
|
|
|
|
this.colorRanges = []
|
2019-07-18 12:26:39 +02:00
|
|
|
this.rangeColors = new Cycle(
|
|
|
|
Colors.eminence,
|
2019-03-21 09:57:27 +01:00
|
|
|
Colors.steelblue,
|
|
|
|
Colors.ochre,
|
2019-07-18 12:26:39 +02:00
|
|
|
Colors.turquoise
|
|
|
|
)
|
2019-03-21 09:57:27 +01:00
|
|
|
this.callbacks = []
|
|
|
|
this.onTapCallbacks = []
|
|
|
|
this.onDoubleTapCallbacks = []
|
|
|
|
this.onLongPressCallbacks = []
|
|
|
|
this.progress = null
|
|
|
|
this.start = null
|
|
|
|
this.end = null
|
|
|
|
this.selection = null
|
|
|
|
this.autoScroll = false
|
|
|
|
this.direction = -1
|
2019-05-14 09:57:28 +02:00
|
|
|
this.throwDamping = throwDamping
|
2019-07-18 12:26:39 +02:00
|
|
|
this.timeticks =
|
|
|
|
ticks ||
|
|
|
|
new TimeTicks(
|
|
|
|
new DecadeTicks(),
|
|
|
|
new YearTicks(),
|
|
|
|
new MonthTicks(),
|
|
|
|
new DayTicks()
|
|
|
|
)
|
2019-03-21 09:57:27 +01:00
|
|
|
this.labelPrefix = '__'
|
|
|
|
}
|
|
|
|
|
|
|
|
updateSelection() {
|
|
|
|
if (this.visibleDate(this.start) && this.visibleDate(this.end)) {
|
|
|
|
this.selection = [this.start, this.end]
|
2019-07-18 12:26:39 +02:00
|
|
|
} else {
|
2019-03-21 09:57:27 +01:00
|
|
|
this.timeticks.selectedRange(this)
|
|
|
|
}
|
|
|
|
|
|
|
|
return this.selection
|
|
|
|
}
|
|
|
|
|
|
|
|
addCallback(callback) {
|
|
|
|
this.callbacks.push(callback)
|
|
|
|
}
|
|
|
|
|
|
|
|
addTabCallback(callback) {
|
|
|
|
this.onTapCallbacks.push(callback)
|
|
|
|
}
|
|
|
|
|
|
|
|
addDoubleTapCallback(callback) {
|
|
|
|
this.onDoubleTapCallbacks.push(callback)
|
|
|
|
}
|
|
|
|
|
|
|
|
addLongPressCallback(callback) {
|
|
|
|
this.onLongPressCallbacks.push(callback)
|
|
|
|
}
|
|
|
|
|
|
|
|
addLabels(labels) {
|
|
|
|
this.labelDates = labels
|
|
|
|
}
|
|
|
|
|
|
|
|
range(start, end) {
|
|
|
|
this.start = start
|
|
|
|
this.end = end
|
|
|
|
}
|
|
|
|
|
|
|
|
draw(width, height) {
|
|
|
|
this.wantedWidth = width
|
|
|
|
this.wantedHeight = height
|
|
|
|
this.redraw()
|
|
|
|
}
|
|
|
|
|
|
|
|
updateColorRanges(w, h) {
|
|
|
|
let xx = w - this.inset
|
|
|
|
let size = FontInfo.small.fontSize
|
|
|
|
let yy = h - size
|
|
|
|
for (let i = this.colorRanges.length - 1; i >= 0; i--) {
|
|
|
|
let cr = this.colorRanges[i]
|
|
|
|
let label = cr.label
|
|
|
|
cr.draw(this, w, h)
|
2019-07-18 12:26:39 +02:00
|
|
|
let current = this.ensureLabel(
|
|
|
|
'colorRange:' + label,
|
|
|
|
label,
|
|
|
|
{ x: xx, y: yy, align: 'right' },
|
|
|
|
FontInfo.small
|
|
|
|
)
|
2019-03-21 09:57:27 +01:00
|
|
|
let r = current.getBounds()
|
|
|
|
xx -= r.width + 16
|
|
|
|
|
|
|
|
this.lineStyle(size, cr.color)
|
|
|
|
this.moveTo(xx, yy)
|
|
|
|
this.lineTo(xx + size, yy)
|
|
|
|
xx -= size + 4
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
drawSelectedRamge(selected) {
|
|
|
|
this.lineStyle(2, app.theme.primaryColor)
|
|
|
|
let start = this.toX(selected[0])
|
|
|
|
let end = this.toX(selected[1])
|
|
|
|
this.moveTo(start, 0)
|
|
|
|
this.lineTo(end, 0)
|
|
|
|
this.drawTick(start, -1.5, 0)
|
|
|
|
this.drawTick(end, -1.5, 0)
|
|
|
|
}
|
|
|
|
|
|
|
|
redraw() {
|
|
|
|
this.clear()
|
|
|
|
let h = this.wantedHeight
|
|
|
|
let w = this.wantedWidth
|
|
|
|
let y = this.getY()
|
|
|
|
this.prepareLabels()
|
|
|
|
this.updateColorRanges(w, h)
|
|
|
|
|
2019-07-18 12:26:39 +02:00
|
|
|
this.lineStyle(2, 0xffffff)
|
2019-03-21 09:57:27 +01:00
|
|
|
if (this.start != null && this.end != null) {
|
|
|
|
this.moveTo(this.toX(this.start), y)
|
|
|
|
this.lineTo(this.toX(this.end), y)
|
|
|
|
this.updateTicksAndLabels(w, h)
|
|
|
|
this.updateSelection()
|
|
|
|
let selected = this.selection
|
|
|
|
if (selected[0] != this.start && selected[1] != this.end) {
|
2019-07-18 12:26:39 +02:00
|
|
|
if (this.showRange) this.drawSelectedRamge(selected)
|
2019-03-21 09:57:27 +01:00
|
|
|
}
|
|
|
|
for (let callback of this.callbacks) {
|
|
|
|
callback(this.scroll, this.zoom, this.selection)
|
|
|
|
}
|
2019-07-18 12:26:39 +02:00
|
|
|
} else {
|
2019-03-21 09:57:27 +01:00
|
|
|
this.moveTo(this.inset, y)
|
|
|
|
this.lineTo(w - this.inset, y)
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.progress != null && this.progress < 1) {
|
2019-07-18 12:26:39 +02:00
|
|
|
this.lineStyle(2, 0xccccff)
|
2019-03-21 09:57:27 +01:00
|
|
|
this.moveTo(this.inset, y)
|
|
|
|
this.lineTo((w - this.inset) * this.progress, y)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
totalWidth(bounded = false) {
|
2019-07-18 12:26:39 +02:00
|
|
|
let w = this.wantedWidth - 2 * this.inset
|
2019-03-21 09:57:27 +01:00
|
|
|
return w * this.validZoom(this.zoom, bounded)
|
|
|
|
}
|
|
|
|
|
|
|
|
validZoom(zoom, bounded = true) {
|
2019-07-18 12:26:39 +02:00
|
|
|
let overshoot = bounded ? 1.0 : 2.0
|
2019-03-21 09:57:27 +01:00
|
|
|
zoom = Math.max(zoom, this.minZoom / overshoot)
|
|
|
|
zoom = Math.min(zoom, this.maxZoom * overshoot)
|
|
|
|
return zoom
|
|
|
|
}
|
|
|
|
|
|
|
|
getY() {
|
|
|
|
return this.wantedHeight * this.baseLine
|
|
|
|
}
|
|
|
|
|
|
|
|
toX(date) {
|
|
|
|
let total = this.end - this.start
|
|
|
|
let offset = this.inset + this.scroll
|
|
|
|
let width = this.totalWidth()
|
|
|
|
let delta = date - this.start
|
|
|
|
let ratio = delta / total
|
|
|
|
return offset + ratio * width
|
|
|
|
}
|
|
|
|
|
|
|
|
fromX(value) {
|
|
|
|
let total = this.end - this.start
|
|
|
|
let offset = this.inset + this.scroll
|
|
|
|
let width = this.totalWidth()
|
|
|
|
let ratio = (value - offset) / width
|
|
|
|
let time = this.start.getTime() + total * ratio
|
|
|
|
let date = new Date(time)
|
|
|
|
return date
|
|
|
|
}
|
|
|
|
|
|
|
|
drawTick(x, direction = 1, y = null) {
|
|
|
|
if (y == null) {
|
|
|
|
y = this.getY()
|
|
|
|
}
|
|
|
|
this.moveTo(x, y)
|
2019-07-18 12:26:39 +02:00
|
|
|
this.lineTo(x, y - this.tickHeight * direction * this.direction)
|
2019-03-21 09:57:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
prepareLabels() {
|
|
|
|
for (let key of this.labels.keys()) {
|
|
|
|
if (!key.startsWith(this.labelPrefix))
|
|
|
|
this.labels.get(key).visible = false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
updateTicksAndLabels(width, height) {
|
|
|
|
this.drawTick(this.toX(this.start))
|
|
|
|
this.drawTick(this.toX(this.end))
|
|
|
|
this.timeticks.draw(this, width, height)
|
|
|
|
this.updateLabels(width, height)
|
|
|
|
}
|
|
|
|
|
|
|
|
visibleDate(date, offset = 0) {
|
|
|
|
if (date >= this.start && date <= this.end) {
|
|
|
|
let x = this.toX(date) + offset
|
2019-07-18 12:26:39 +02:00
|
|
|
return x > 0 && x < this.wantedWidth
|
2019-03-21 09:57:27 +01:00
|
|
|
}
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
visibleRange(start, end) {
|
|
|
|
let x = this.toX(start)
|
2019-07-18 12:26:39 +02:00
|
|
|
if (x > this.wantedWidth) return false
|
2019-03-21 09:57:27 +01:00
|
|
|
x = this.toX(end)
|
2019-07-18 12:26:39 +02:00
|
|
|
if (x < 0) return false
|
2019-03-21 09:57:27 +01:00
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
tickLabelOffset(direction = 1, level = 0) {
|
|
|
|
let fs = FontInfo.small.fontSize
|
2019-07-18 12:26:39 +02:00
|
|
|
let dh = fs + level * (fs + 2)
|
2019-03-21 09:57:27 +01:00
|
|
|
return this.direction * direction * dh
|
|
|
|
}
|
|
|
|
|
|
|
|
updateLabels(width, height) {
|
|
|
|
let h2 = height / 2
|
|
|
|
if (this.labelDates) {
|
|
|
|
let last = null
|
|
|
|
let y = h2 + this.tickLabelOffset()
|
|
|
|
for (let i = this.labelDates.length - 1; i >= 0; i--) {
|
|
|
|
let [label, date] = this.labelDates[i]
|
|
|
|
let align = 'center' // (last == null) ? 'right' : 'left'
|
|
|
|
let x = this.toX(date)
|
2019-07-18 12:26:39 +02:00
|
|
|
let current = this.ensureLabel(
|
|
|
|
this.labelPrefix + label,
|
|
|
|
label,
|
2019-03-21 09:57:27 +01:00
|
|
|
{
|
2019-07-18 12:26:39 +02:00
|
|
|
x: x,
|
|
|
|
y: y,
|
2019-03-21 09:57:27 +01:00
|
|
|
align
|
|
|
|
},
|
2019-07-18 12:26:39 +02:00
|
|
|
FontInfo.small
|
|
|
|
)
|
2019-03-21 09:57:27 +01:00
|
|
|
let r = current.getBounds()
|
|
|
|
current.visible = !(last != null && r.x + r.width > last.x)
|
|
|
|
if (current.visible) {
|
|
|
|
this.drawTick(x, -1)
|
|
|
|
last = r
|
|
|
|
}
|
|
|
|
}
|
2019-07-18 12:26:39 +02:00
|
|
|
} else {
|
|
|
|
let start = this.start.toLocaleDateString('de', {
|
|
|
|
year: 'numeric',
|
|
|
|
month: 'numeric',
|
|
|
|
day: 'numeric'
|
|
|
|
})
|
|
|
|
let end = this.end.toLocaleDateString('de', {
|
|
|
|
year: 'numeric',
|
|
|
|
month: 'numeric',
|
|
|
|
day: 'numeric'
|
|
|
|
})
|
|
|
|
this.ensureLabel(this.labelPrefix + 'start', start, {
|
|
|
|
x: this.toX(this.start),
|
|
|
|
y: h2
|
|
|
|
})
|
|
|
|
this.ensureLabel(this.labelPrefix + 'end', end, {
|
|
|
|
x: this.toX(this.end),
|
|
|
|
y: h2,
|
|
|
|
align: 'right'
|
|
|
|
})
|
2019-03-21 09:57:27 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
onZoom(zoom, anchor) {
|
|
|
|
let date = this.fromX(anchor.x)
|
|
|
|
let newZoom = this.validZoom(this.zoom * zoom, false)
|
|
|
|
this.zoom = newZoom
|
|
|
|
let newX = this.toX(date)
|
|
|
|
this.scroll += anchor.x - newX
|
|
|
|
}
|
|
|
|
|
|
|
|
onStart(event, interaction) {
|
|
|
|
this.killTweens()
|
|
|
|
this.deltas = []
|
|
|
|
this.validScroll()
|
2019-07-18 12:26:39 +02:00
|
|
|
if (typeof ThrowPropsPlugin != 'undefined') {
|
2019-03-29 09:11:21 +01:00
|
|
|
ThrowPropsPlugin.track(this, 'delta')
|
|
|
|
}
|
2019-03-21 09:57:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
onMove(event, interaction) {
|
|
|
|
let delta = interaction.delta()
|
2019-07-10 14:20:58 +02:00
|
|
|
if (delta == null) {
|
|
|
|
return
|
|
|
|
}
|
2019-03-21 09:57:27 +01:00
|
|
|
this.scroll += delta.x
|
|
|
|
while (this.deltas.length > 10) {
|
|
|
|
this.deltas.pop(0)
|
|
|
|
}
|
|
|
|
this.deltas.push(delta.x)
|
|
|
|
if (interaction.current.size > 1) {
|
|
|
|
this.onZoom(delta.zoom, delta.about)
|
|
|
|
}
|
|
|
|
this.redraw()
|
|
|
|
}
|
|
|
|
|
|
|
|
onEnd(event, interaction) {
|
2019-07-18 12:26:39 +02:00
|
|
|
if (typeof ThrowPropsPlugin != 'undefined') {
|
2019-03-29 09:11:21 +01:00
|
|
|
let vel = ThrowPropsPlugin.getVelocity(this, 'delta')
|
|
|
|
ThrowPropsPlugin.untrack(this)
|
|
|
|
}
|
2019-03-21 09:57:27 +01:00
|
|
|
|
|
|
|
this.killTweens()
|
|
|
|
this.redraw()
|
|
|
|
let delta = 0
|
|
|
|
for (let d of this.deltas) {
|
|
|
|
delta += d
|
|
|
|
}
|
|
|
|
if (this.deltas.length > 0) {
|
|
|
|
delta /= this.deltas.length
|
|
|
|
}
|
|
|
|
this.autoScroll = true
|
|
|
|
let anchor = interaction.current.mean()
|
|
|
|
this.keepInBounds(delta, anchor)
|
|
|
|
|
2019-07-18 12:26:39 +02:00
|
|
|
for (let key of interaction.ended.keys()) {
|
2019-03-21 09:57:27 +01:00
|
|
|
if (interaction.isDoubleTap(key)) {
|
|
|
|
this.onDoubleTap(event, interaction, key)
|
2019-07-18 12:26:39 +02:00
|
|
|
} else if (interaction.isTap(key)) {
|
2019-03-21 09:57:27 +01:00
|
|
|
this.onTap(event, interaction, key)
|
2019-07-18 12:26:39 +02:00
|
|
|
} else if (interaction.isLongPress(key)) {
|
2019-03-21 09:57:27 +01:00
|
|
|
this.onLongPress(event, interaction, key)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
onLongPress(event, interaction, key) {
|
2019-07-18 12:26:39 +02:00
|
|
|
for (let callback of this.onLongPressCallbacks) {
|
2019-03-21 09:57:27 +01:00
|
|
|
callback(event, interaction, key)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
onTap(event, interaction, key) {
|
2019-07-18 12:26:39 +02:00
|
|
|
for (let callback of this.onTapCallbacks) {
|
2019-03-21 09:57:27 +01:00
|
|
|
callback(event, interaction, key)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
onDoubleTap(event, interaction, key) {
|
2019-07-18 12:26:39 +02:00
|
|
|
for (let callback of this.onDoubleTapCallbacks) {
|
2019-03-21 09:57:27 +01:00
|
|
|
callback(event, interaction, key)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
_scrollMinimum(bounded) {
|
|
|
|
let total = this.totalWidth(bounded)
|
|
|
|
return -total + this.wantedWidth - 2 * this.inset
|
|
|
|
}
|
|
|
|
|
|
|
|
_scrollMaximum(bounded) {
|
|
|
|
let total = this.totalWidth(bounded)
|
|
|
|
let limit = this.wantedWidth
|
2019-07-18 12:26:39 +02:00
|
|
|
if (total > limit) return 0
|
2019-03-21 09:57:27 +01:00
|
|
|
let w = limit - 2 * this.inset
|
|
|
|
return (w - total) / 2
|
|
|
|
}
|
|
|
|
|
|
|
|
scrollMinimum(bounded) {
|
|
|
|
return this._scrollMinimum(bounded) - this.extraRight
|
|
|
|
}
|
|
|
|
|
|
|
|
scrollMaximum(bounded) {
|
|
|
|
return this._scrollMaximum(bounded) + this.extraLeft
|
|
|
|
}
|
|
|
|
|
|
|
|
killTweens() {
|
|
|
|
TweenLite.killTweensOf(this)
|
|
|
|
this.autoScroll = false
|
|
|
|
}
|
|
|
|
|
|
|
|
validScroll(bounded = true) {
|
|
|
|
let minimum = this.scrollMinimum(bounded)
|
|
|
|
let maximum = this.scrollMaximum(bounded)
|
|
|
|
if (this.scroll < minimum) {
|
|
|
|
this.scroll = minimum
|
|
|
|
}
|
|
|
|
if (this.scroll > maximum) {
|
|
|
|
this.scroll = maximum
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
keepInBounds(delta, anchor) {
|
|
|
|
let bounded = true
|
|
|
|
let minimum = this.scrollMinimum(bounded)
|
|
|
|
let maximum = this.scrollMaximum(bounded)
|
|
|
|
let tweens = {}
|
|
|
|
if (this.zoom > this.maxZoom) {
|
|
|
|
tweens.zoom = this.maxZoom
|
|
|
|
let date = this.fromX(anchor.x)
|
|
|
|
let oldZoom = this.zoom
|
|
|
|
this.zoom = this.maxZoom
|
|
|
|
let newX = this.toX(date)
|
|
|
|
tweens.scroll = this.scroll + anchor.x - newX
|
|
|
|
this.zoom = oldZoom
|
2019-07-18 12:26:39 +02:00
|
|
|
} else {
|
2019-03-21 09:57:27 +01:00
|
|
|
if (this.zoom < this.minZoom) {
|
|
|
|
tweens.zoom = this.minZoom
|
|
|
|
}
|
|
|
|
if (this.scroll > maximum) {
|
|
|
|
tweens.scroll = maximum
|
|
|
|
}
|
|
|
|
if (this.scroll < minimum) {
|
|
|
|
tweens.scroll = minimum
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (!isEmpty(tweens)) {
|
2019-07-18 12:26:39 +02:00
|
|
|
tweens.onUpdate = () => {
|
|
|
|
this.redraw()
|
|
|
|
}
|
2019-03-21 09:57:27 +01:00
|
|
|
TweenLite.to(this, 0.5, tweens).delay(0.1)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
this.scroll += delta
|
2019-05-14 09:57:28 +02:00
|
|
|
delta *= this.throwDamping
|
2019-03-21 09:57:27 +01:00
|
|
|
this.redraw()
|
|
|
|
if (Math.abs(delta) > 1 && this.autoScroll) {
|
|
|
|
setTimeout(() => this.keepInBounds(delta, anchor), 1000 / 100)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
onMouseWheel(event) {
|
|
|
|
this.killTweens()
|
|
|
|
let direction = event.detail < 0 || event.wheelDelta > 0
|
|
|
|
let anchor = { x: event.clientX, y: event.clientY }
|
|
|
|
const zoomFactor = 1.5
|
2019-07-18 12:26:39 +02:00
|
|
|
this.onZoom(direction ? zoomFactor : 1 / zoomFactor, anchor)
|
2019-03-21 09:57:27 +01:00
|
|
|
this.redraw()
|
|
|
|
this.keepInBounds(0, anchor)
|
|
|
|
}
|
|
|
|
|
2019-07-18 12:26:39 +02:00
|
|
|
showRanges(ranges, label = 'Untitled', color = null) {
|
2019-03-21 09:57:27 +01:00
|
|
|
for (let cr of this.colorRanges) {
|
2019-07-18 12:26:39 +02:00
|
|
|
if (cr.label == label) return
|
2019-03-21 09:57:27 +01:00
|
|
|
}
|
|
|
|
while (this.colorRanges.length >= this.rangeColors.length) {
|
|
|
|
this.colorRanges.shift()
|
|
|
|
}
|
|
|
|
this.colorRanges.push(new ColorRanges(label, color, ranges))
|
|
|
|
this.redraw()
|
|
|
|
}
|
|
|
|
}
|