#!/usr/bin/env python3

from collections import namedtuple
from datetime import datetime, timedelta
from math import ceil

from dom import Document
from pysvgi import Circle, Line, Svg, Text


class DatetimeUtil:
    """Adds some additional methods to the standard datetime class"""

    # tuple as generated by datetime.timetuple:
    #
    # time.struct_time(
    #   tm_year=2017,
    #   tm_mon=9,
    #   tm_mday=17,
    #   tm_hour=0,
    #   tm_min=37,
    #   tm_sec=30,
    #   tm_wday=6,
    #   tm_yday=260,
    #   tm_isdst=-1)

    ResolutionData = namedtuple(
        'ResolutionData',
        ['mask', 'timedelta_args'])

    RESOLUTIONS = {
        'hour': ResolutionData(
            (1, 1, 1, 1, 0, 0, 0),
            {'seconds': 60 * 60})
    }

    @staticmethod
    def round_down(datetime_in, resolution):
        if resolution not in DatetimeUtil.RESOLUTIONS:
            raise ValueError("Invalid resolution %s" % resolution)
        mask = DatetimeUtil.RESOLUTIONS[resolution].mask
        dateTuple = datetime_in.timetuple()[:7]
        args = [a * b for a, b in zip(dateTuple, mask)]
        return datetime(*args)

    @staticmethod
    def round_up(datetime_in, resolution):
        rounded_datetime = DatetimeUtil.round_down(datetime_in, resolution)
        timedelta_args = DatetimeUtil.RESOLUTIONS[resolution].timedelta_args
        return rounded_datetime + timedelta(**timedelta_args)
        
        
class Event:
    
    DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
    
    def __init__(self, startTime):
        self.startTime = startTime
        self.x = None
        self.y = None
        
    def onRow(self, row):
        return row.startTime <= self.startTime < row.nextRowStartTime
        
    def __repr__(self):
        return '<Event %s>' % self.startTime.strftime(self.DATE_FORMAT)
        
    def __eq__(self, rhs):
        if rhs is None:
            return False
        if type(rhs) != type(self):
            return False
        return self.startTime == rhs.startTime
        
    def layout(self, row):
        self.x = row.datetimeToX(self.startTime)
        self.y = row.baseY
        
    def render(self):
        print('rendering event')
        return [Circle(5, self.x, self.y)]



class EventSpan(Event):
    def __init__(self, startTime, endTime):
        super().__init__(startTime)
        self.endTime = endTime
        
    def split(self, splitTime, resolutionTimedelta):
        if not (self.startTime < splitTime < self.endTime):
            return self, None
        remainderEvent = EventSpan(splitTime, self.endTime)
        self.endTime = splitTime - resolutionTimedelta
        return self, remainderEvent
        
    def __repr__(self):
        return '<EventSpan %s - %s>' % \
            (self.startTime.strftime(self.DATE_FORMAT),
            self.endTime.strftime(self.DATE_FORMAT))


class EventSeries(list):
    
    def __init__(self, name, color, iterable=None):
        if iterable is None:
            super().__init__()
        else:
            super().__init__(iterable)
        self.name = name
        self.color = color
        
    def append(self, event):
        event.color = self.color
        super().append(event)
        
    def sort(self):
        super().sort(key=lambda i: i.startTime)
        
    def getEventsOnRow(self, row, resolutionTimedelta):
        eventsOut = []
        remnants = []
        
        for event in self:
            if not event.onRow(row):
                continue
            
            # if isinstance(event, EventSpan):
                # event, remnant = event.split(
                    # row.nextRowStartTime, 
                    # resolutionTimedelta)
                # remnants.append(remnant)
                
            eventsOut.append(event)
            
        for remnant in remnants:
            self.insert(0, remnant)
            
        return eventsOut


class Row:
    def __init__(
            self,
            startTime,
            nextRowStartTime,
            minX,
            maxX,
            baseY):
    
        self.startTime = startTime
        self.nextRowStartTime = nextRowStartTime
        self.minX = minX
        self.maxX = maxX
        self.baseY = baseY
        
        self.recalculate()
        
    def recalculate(self):
        self.width = self.maxX - self.minX
        self.startTimestamp = self.startTime.timestamp()
        self.timeSpan = self.nextRowStartTime.timestamp() - self.startTimestamp

    def datetimeToX(self, datetimeIn):
        timeSinceStart = datettimeIn.timestamp() - self.startTimestamp
        temp = (timeSinceStart / self.timeSpan) * self.width
        return self.minX + int(temp)
        

class CyclicTimeline(Svg):

    MARGIN_TOP = 50
    MARGIN_BOTTOM = 50
    MARGIN_LEFT = 50
    MARGIN_RIGHT = 50
    
    SPACE_FOR_DATE = 100
    LINE_LENGTH = 300
    
    RESOLUTION_TIMEDELTA = timedelta(seconds=60) 

    CycleConfiguration = namedtuple(
        'CycleConfiguration',
        [
            'timedeltaArgs',
            'dateFormat'
        ])

    CYCLE_LENGTHS = {
        'hour': CycleConfiguration(
            {'seconds': 3600},
            '%H:%M'),
        'day': CycleConfiguration(
            {'days': 1},
            '%Y-%m-%d'),
        'week': CycleConfiguration(
            {'days': 7},
            '%Y-%m-%d'),
        'month': None,
        'year': CycleConfiguration(
            {'days': 365.25},
            '%Y')
    }

    ROW_HEIGHT = 30

    def __init__(
            self,
            startDate,
            endDate,
            cycleLength='hour',
            dateFormat=None):

        super().__init__()

        self.startDate = DatetimeUtil.round_down(startDate, cycleLength)
        self.endDate = DatetimeUtil.round_up(endDate, cycleLength)

        self.config = self.CYCLE_LENGTHS.get(cycleLength, None)
        if self.config is None:
            raise ValueError("Invalid cycleLength %s" % self.cycleLength)

        self.cycleLength = cycleLength

        if dateFormat is None:
            self.dateFormat = self.config.dateFormat
        else:
            self.dateFormat = dateFormat

        self.allEventSeries = []
        self.rowStartTimes = {}

    def getRowCount(self, span):
        if self.cycleLength == 'hour':
            return ceil(span.total_seconds() / 60.0 / 60.0)
        raise ValueError('%s cycleLength not implemented' % self.cycleLength)

    def getRowStartTime(self, rowIndex):
        # Note: I'm using integer keys into a dict
        if rowIndex in self.rowStartTimes:
            return self.rowStartTimes[rowIndex]
        
        timedeltaArgs = self.config.timedeltaArgs
        timedeltaArgs = {
            k: v * rowIndex for k, v in list(timedeltaArgs.items())
        }
        startTime = self.startDate + timedelta(**timedeltaArgs)
        self.rowStartTimes[rowIndex] = startTime
        
        twoBack = rowIndex - 2
        if twoBack in self.rowStartTimes:
            self.rowStartTimes.pop(twoBack)
            
        return startTime
        
    def sortEvents(self):
        for eventSeries in self.allEventSeries:
            eventSeries.sort()

    def getEventsOnThisRow(self, row):
        eventsOut = []
        for eventSeries in self.allEventSeries:
            eventsOut += eventSeries.getEventsOnRow(
                    row,
                    self.RESOLUTION_TIMEDELTA)
        eventsOut.sort()
        return eventsOut
        

    def layoutEvents(self, rowEvents, row):
        placedEvents = []
        maxY = row.baseY 
        for i, event in enumerate(rowEvents):
            event.layout(row)
            
        return maxY
        
              
    def renderCycleLine(self, y, rowStartTime, lineMinX, lineMaxX):
        text = Text(
            rowStartTime.strftime(self.dateFormat),
            self.MARGIN_LEFT,
            y,
            text_anchor='start')
        self.append(text)
        line = Line(lineMinX, y, lineMaxX, y) 
        self.append(line)
        
    def renderEvents(self):
        for eventSeries in self.allEventSeries:
            for event in eventSeries:
                for element in event.render():
                    self.append(element)
          
    def build(self):
        span = self.endDate - self.startDate
        print('timelineSpan', span)

        rows = self.getRowCount(span)

        self.sortEvents()

        y = self.MARGIN_TOP
        lineMinX = self.MARGIN_LEFT + self.SPACE_FOR_DATE
        lineMaxX = self.MARGIN_LEFT + self.SPACE_FOR_DATE + self.LINE_LENGTH
        
        for i in range(rows):
            rowStartTime = self.getRowStartTime(i)
            nextRowStartTime = self.getRowStartTime(i)
            
            row = Row(
                rowStartTime, 
                nextRowStartTime,
                lineMinX,
                lineMaxX,
                y)
            
            rowEvents = self.getEventsOnThisRow(row)
            
            y = self.layoutEvents(
                rowEvents, 
                row)
            
            self.renderCycleLine(y, rowStartTime, lineMinX, lineMaxX)
            
            self.renderEvents()
            
            y += 20
            
            
            

        self.height = y + self.MARGIN_BOTTOM
        self.width = self.MARGIN_LEFT + self.SPACE_FOR_DATE + \
            self.LINE_LENGTH + self.MARGIN_RIGHT


    def toprettyxml(self, indent='\t', newl='\n', encoding=''):
        self.build()
        return super().toprettyxml(indent, newl, encoding)

    def __str__(self):
        return super().__str__()


if __name__ == '__main__':
    timeline = CyclicTimeline(
        datetime(2017, 9, 10, 9),
        datetime(2017, 9, 10, 17),
        'hour',
        '%I:%M %p')
    
    series1 = EventSeries('series1', 'green')
    series1.append(Event(datetime(2017, 9, 10, 9, 30)))
    series1.append(
        EventSpan(
            datetime(2017, 9, 10, 9, 40),
            datetime(2017, 9, 10, 9, 50)))
    
    timeline.allEventSeries = [series1]
    

    with open('timeline.svg', 'w') as outFile:
        outFile.write(Document.XML_LINE + '\n')
        outFile.write(timeline.toprettyxml())

#!/usr/bin/env python3

from collections import namedtuple
from datetime import datetime, timedelta
from math import ceil

from dom import Document
from pysvgi import Circle, Line, Svg, Text


class DatetimeUtil:
    """Adds some additional methods to the standard datetime class"""

    # tuple as generated by datetime.timetuple:
    #
    # time.struct_time(
    #   tm_year=2017,
    #   tm_mon=9,
    #   tm_mday=17,
    #   tm_hour=0,
    #   tm_min=37,
    #   tm_sec=30,
    #   tm_wday=6,
    #   tm_yday=260,
    #   tm_isdst=-1)

    ResolutionData = namedtuple(
        'ResolutionData',
        ['mask', 'timedelta_args'])

    RESOLUTIONS = {
        'hour': ResolutionData(
            (1, 1, 1, 1, 0, 0, 0),
            {'seconds': 60 * 60})
    }

    @staticmethod
    def round_down(datetime_in, resolution):
        if resolution not in DatetimeUtil.RESOLUTIONS:
            raise ValueError("Invalid resolution %s" % resolution)
        mask = DatetimeUtil.RESOLUTIONS[resolution].mask
        dateTuple = datetime_in.timetuple()[:7]
        args = [a * b for a, b in zip(dateTuple, mask)]
        return datetime(*args)

    @staticmethod
    def round_up(datetime_in, resolution):
        rounded_datetime = DatetimeUtil.round_down(datetime_in, resolution)
        timedelta_args = DatetimeUtil.RESOLUTIONS[resolution].timedelta_args
        return rounded_datetime + timedelta(**timedelta_args)
        
        
class Event:
    
    DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
    
    def __init__(self, startTime):
        self.startTime = startTime
        self.x = None
        self.y = None
        
    def onRow(self, row):
        return row.startTime <= self.startTime < row.nextRowStartTime
        
    def __repr__(self):
        return '<Event %s>' % self.startTime.strftime(self.DATE_FORMAT)
        
    def __eq__(self, rhs):
        if rhs is None:
            return False
        if type(rhs) != type(self):
            return False
        return self.startTime == rhs.startTime
        
    def layout(self, row):
        self.x = row.datetimeToX(self.startTime)
        self.y = row.baseY
        
    def render(self):
        print('rendering event')
        return [Circle(5, self.x, self.y)]



class EventSpan(Event):
    def __init__(self, startTime, endTime):
        super().__init__(startTime)
        self.endTime = endTime
        
    def split(self, splitTime, resolutionTimedelta):
        if not (self.startTime < splitTime < self.endTime):
            return self, None
        remainderEvent = EventSpan(splitTime, self.endTime)
        self.endTime = splitTime - resolutionTimedelta
        return self, remainderEvent
        
    def __repr__(self):
        return '<EventSpan %s - %s>' % \
            (self.startTime.strftime(self.DATE_FORMAT),
            self.endTime.strftime(self.DATE_FORMAT))


class EventSeries(list):
    
    def __init__(self, name, color, iterable=None):
        if iterable is None:
            super().__init__()
        else:
            super().__init__(iterable)
        self.name = name
        self.color = color
        
    def append(self, event):
        event.color = self.color
        super().append(event)
        
    def sort(self):
        super().sort(key=lambda i: i.startTime)
        
    def getEventsOnRow(self, row, resolutionTimedelta):
        eventsOut = []
        remnants = []
        
        for event in self:
            if not event.onRow(row):
                continue
            
            # if isinstance(event, EventSpan):
                # event, remnant = event.split(
                    # row.nextRowStartTime, 
                    # resolutionTimedelta)
                # remnants.append(remnant)
                
            eventsOut.append(event)
            
        for remnant in remnants:
            self.insert(0, remnant)
            
        return eventsOut


class Row:
    def __init__(
            self,
            startTime,
            nextRowStartTime,
            minX,
            maxX,
            baseY):
    
        self.startTime = startTime
        self.nextRowStartTime = nextRowStartTime
        self.minX = minX
        self.maxX = maxX
        self.baseY = baseY
        
        self.recalculate()
        
    def recalculate(self):
        self.width = self.maxX - self.minX
        self.startTimestamp = self.startTime.timestamp()
        self.timeSpan = self.nextRowStartTime.timestamp() - self.startTimestamp

    def datetimeToX(self, datetimeIn):
        timeSinceStart = datettimeIn.timestamp() - self.startTimestamp
        temp = (timeSinceStart / self.timeSpan) * self.width
        return self.minX + int(temp)
        

class CyclicTimeline(Svg):

    MARGIN_TOP = 50
    MARGIN_BOTTOM = 50
    MARGIN_LEFT = 50
    MARGIN_RIGHT = 50
    
    SPACE_FOR_DATE = 100
    LINE_LENGTH = 300
    
    RESOLUTION_TIMEDELTA = timedelta(seconds=60) 

    CycleConfiguration = namedtuple(
        'CycleConfiguration',
        [
            'timedeltaArgs',
            'dateFormat'
        ])

    CYCLE_LENGTHS = {
        'hour': CycleConfiguration(
            {'seconds': 3600},
            '%H:%M'),
        'day': CycleConfiguration(
            {'days': 1},
            '%Y-%m-%d'),
        'week': CycleConfiguration(
            {'days': 7},
            '%Y-%m-%d'),
        'month': None,
        'year': CycleConfiguration(
            {'days': 365.25},
            '%Y')
    }

    ROW_HEIGHT = 30

    def __init__(
            self,
            startDate,
            endDate,
            cycleLength='hour',
            dateFormat=None):

        super().__init__()

        self.startDate = DatetimeUtil.round_down(startDate, cycleLength)
        self.endDate = DatetimeUtil.round_up(endDate, cycleLength)

        self.config = self.CYCLE_LENGTHS.get(cycleLength, None)
        if self.config is None:
            raise ValueError("Invalid cycleLength %s" % self.cycleLength)

        self.cycleLength = cycleLength

        if dateFormat is None:
            self.dateFormat = self.config.dateFormat
        else:
            self.dateFormat = dateFormat

        self.allEventSeries = []
        self.rowStartTimes = {}

    def getRowCount(self, span):
        if self.cycleLength == 'hour':
            return ceil(span.total_seconds() / 60.0 / 60.0)
        raise ValueError('%s cycleLength not implemented' % self.cycleLength)

    def getRowStartTime(self, rowIndex):
        # Note: I'm using integer keys into a dict
        if rowIndex in self.rowStartTimes:
            return self.rowStartTimes[rowIndex]
        
        timedeltaArgs = self.config.timedeltaArgs
        timedeltaArgs = {
            k: v * rowIndex for k, v in list(timedeltaArgs.items())
        }
        startTime = self.startDate + timedelta(**timedeltaArgs)
        self.rowStartTimes[rowIndex] = startTime
        
        twoBack = rowIndex - 2
        if twoBack in self.rowStartTimes:
            self.rowStartTimes.pop(twoBack)
            
        return startTime
        
    def sortEvents(self):
        for eventSeries in self.allEventSeries:
            eventSeries.sort()

    def getEventsOnThisRow(self, row):
        eventsOut = []
        for eventSeries in self.allEventSeries:
            eventsOut += eventSeries.getEventsOnRow(
                    row,
                    self.RESOLUTION_TIMEDELTA)
        eventsOut.sort()
        return eventsOut
        

    def layoutEvents(self, rowEvents, row):
        placedEvents = []
        maxY = row.baseY 
        for i, event in enumerate(rowEvents):
            event.layout(row)
            
        return maxY
        
              
    def renderCycleLine(self, y, rowStartTime, lineMinX, lineMaxX):
        text = Text(
            rowStartTime.strftime(self.dateFormat),
            self.MARGIN_LEFT,
            y,
            text_anchor='start')
        self.append(text)
        line = Line(lineMinX, y, lineMaxX, y) 
        self.append(line)
        
    def renderEvents(self):
        for eventSeries in self.allEventSeries:
            for event in eventSeries:
                for element in event.render():
                    self.append(element)
          
    def build(self):
        span = self.endDate - self.startDate
        print('timelineSpan', span)

        rows = self.getRowCount(span)

        self.sortEvents()

        y = self.MARGIN_TOP
        lineMinX = self.MARGIN_LEFT + self.SPACE_FOR_DATE
        lineMaxX = self.MARGIN_LEFT + self.SPACE_FOR_DATE + self.LINE_LENGTH
        
        for i in range(rows):
            rowStartTime = self.getRowStartTime(i)
            nextRowStartTime = self.getRowStartTime(i)
            
            row = Row(
                rowStartTime, 
                nextRowStartTime,
                lineMinX,
                lineMaxX,
                y)
            
            rowEvents = self.getEventsOnThisRow(row)
            
            y = self.layoutEvents(
                rowEvents, 
                row)
            
            self.renderCycleLine(y, rowStartTime, lineMinX, lineMaxX)
            
            self.renderEvents()
            
            y += 20
            
            
            

        self.height = y + self.MARGIN_BOTTOM
        self.width = self.MARGIN_LEFT + self.SPACE_FOR_DATE + \
            self.LINE_LENGTH + self.MARGIN_RIGHT


    def toprettyxml(self, indent='\t', newl='\n', encoding=''):
        self.build()
        return super().toprettyxml(indent, newl, encoding)

    def __str__(self):
        return super().__str__()


if __name__ == '__main__':
    timeline = CyclicTimeline(
        datetime(2017, 9, 10, 9),
        datetime(2017, 9, 10, 17),
        'hour',
        '%I:%M %p')
    
    series1 = EventSeries('series1', 'green')
    series1.append(Event(datetime(2017, 9, 10, 9, 30)))
    series1.append(
        EventSpan(
            datetime(2017, 9, 10, 9, 40),
            datetime(2017, 9, 10, 9, 50)))
    
    timeline.allEventSeries = [series1]
    

    with open('timeline.svg', 'w') as outFile:
        outFile.write(Document.XML_LINE + '\n')
        outFile.write(timeline.toprettyxml())

#!/usr/bin/env python3 Adds some additional methods to the standard datetime class # tuple as generated by datetime.timetuple: # # time.struct_time( #   tm_year=2017, #   tm_mon=9, #   tm_mday=17, #   tm_hour=0, #   tm_min=37, #   tm_sec=30, #   tm_wday=6, #   tm_yday=260, #   tm_isdst=-1) # if isinstance(event, EventSpan): # event, remnant = event.split( # row.nextRowStartTime, # resolutionTimedelta) # remnants.append(remnant) # Note: I'm using integer keys into a dict