swapp.ls 9.2 KB
# Livescript transpiles to javascript, and is easier on the eyes and brain.
# http://livescript.net

const GOLDEN_RATIO = 2 / (1 + Math.sqrt(5))

export class TimeSeries
  # Time in x-axis
  # Data in y-axis

  (@title, @data, @container, @options = {}) ->
    # title : string
    # data : list of {x: <datetime>, y: <float>}
    console.log "Create time series '#{@title}'"
    @init()

  init: ->
    console.log "Initialize time series '#{@title}'", @data, @options

    @margin = {
      top: 30,
      right: 20,
      bottom: 30,
      left: 60
    }

    @xScale = d3.scaleTime().domain(d3.extent(@data, (d) -> d.x))
    @yScale = d3.scaleLinear().domain(d3.extent(@data, (d) -> d.y))

    #console.info("Y domain #{@title}", d3.extent(@data, (d) -> d.y))

    @xAxis = d3.axisBottom()
               .ticks(7, ",f")
               .tickFormat(d3.timeFormat("%Y-%m-%d"))
    @yAxis = d3.axisLeft()
               .ticks(10)

    @line = d3.line()
              .x((d) ~> @xScale(d.x))
              .y((d) ~> @yScale(d.y))

    @svg = d3.select(@container).append('svg')


    @plotWrapper = @svg.append('g')
    @plotWrapper.attr('transform', 'translate(' + @margin.left + ',' + @margin.top + ')')

    @path = @plotWrapper.append('path')
                        .datum(@data)
                        .classed('line', true)

    @mouseCanvas = @plotWrapper.append("rect")
                               .style("fill", "none")
                               .style("pointer-events", "all")
    @mouseCanvas
        .on("mouseover", @onMouseOver)
        .on("mouseout",  @onMouseOut)
        .on("mousemove", @onMouseMove)

    @plotWrapper.append('g').classed('x axis', true)
    @plotWrapper.append('g').classed('y axis', true)
    @yAxisText = @plotWrapper.append("text")
        .attr("transform", "rotate(-90)")
        .attr("dy", "1em")
        .style("text-anchor", "middle")
        .text(@title)

    @focus = @plotWrapper.append('g').style("display", "none")

    @cursorCircle = @focus.append("circle")
                         .attr("class", "cursor-circle")
                         .attr("r", 3)

    dx = 8
    @cursorValueShadow = @focus.append("text")
                              .attr("class", "cursor-text cursor-text-shadow")
                              .attr("dx", dx)
                              .attr("dy", "-.3em")

    @cursorValue = @focus.append("text")
                        .attr("class", "cursor-text cursor-value")
                        .attr("dx", dx)
                        .attr("dy", "-.3em")


    @cursorDateShadow = @focus.append("text")
                              .attr("class", "cursor-text cursor-text-shadow")
                              .attr("dx", dx)
                              .attr("dy", "1em")

    @cursorDate = @focus.append("text")
                        .attr("class", "cursor-text cursor-date")
                        .attr("dx", dx)
                        .attr("dy", "1em")

    @resize()

  resize: ->
    width = jQuery(@container).width() - @margin.left - @margin.right
    height = GOLDEN_RATIO * GOLDEN_RATIO * GOLDEN_RATIO * GOLDEN_RATIO * width

    @plotWidth = width
    @plotHeight = height

    console.log("Resize time series #{@title} : #{width} x #{height}")

    @xScale.range([0, width]);
    @yScale.range([height, 0]);

    @svg.attr('width',  width + @margin.right + @margin.left)
        .attr('height', height + @margin.top + @margin.bottom)

    @path.attr('d', @line) # wip, do we need to put this in resize ?

    @xAxis.scale(@xScale)
    @yAxis.scale(@yScale)

    #if width < 600 then @xAxis.ticks(3) else @xAxis.ticks(7, ",f")
    @xAxis.ticks(Math.floor(width / 60))
    @yAxis.ticks(Math.floor(height / 18))

    @svg.select('.x.axis')
        .attr('transform', 'translate(0,' + height + ')')
        .call(@xAxis)

    @svg.select('.y.axis')
        .call(@yAxis)

    @yAxisText.attr("y", 0 - @margin.left)
              .attr("x", 0 - (height / 2))

    @mouseCanvas.attr("width", width)
                .attr("height", height)

    this

  onMouseMove: ~>
    x = @xScale.invert(d3.mouse(@mouseCanvas.node())[0])
    if @options.onMouseMove?
      @options.onMouseMove(x)
    else
      @moveCursor(x)

  onMouseOver: ~>
    if @options.onMouseOver?
      @options.onMouseOver()
    else
      @showCursor()

  onMouseOut: ~>
    if @options.onMouseOut?
      @options.onMouseOut()
    else
      @hideCursor()

  showCursor: ->
    @focus.style("display", null)

  hideCursor: ->
    @focus.style("display", "none")

  bisectDate: d3.bisector((d) -> d.x).left # /!\ complex
  timeFormat: d3.timeFormat("%Y-%m-%d %Hh")

  moveCursor: (x0) ->
    i = @bisectDate(@data, x0, 1)
    d0 = @data[i - 1]
    d1 = @data[i]
    return unless d1 and d0
    d = if x0 - d0.x > d1.x - x0 then d1 else d0
    xx = @xScale(d.x)
    yy = @yScale(d.y)

    transform = "translate(#{xx}, #{yy})"

    mirrored = if @plotWidth? and xx > @plotWidth / 2 then true else false
#    console.log("xx", xx)

    dx = 8
    dx = -1 * dx if mirrored

    @cursorCircle.attr("transform", transform)
    @cursorValue.attr("transform", transform).text(d.y)
                .attr('text-anchor', if mirrored then 'end' else 'start')
                .attr("dx", dx)
    @cursorValueShadow.attr("transform", transform).text(d.y)
                      .attr('text-anchor', if mirrored then 'end' else 'start')
                      .attr("dx", dx)
    @cursorDate.attr("transform", transform).text(@timeFormat(d.x))
               .attr('text-anchor', if mirrored then 'end' else 'start')
               .attr("dx", dx)
    @cursorDateShadow.attr("transform", transform).text(@timeFormat(d.x))
                     .attr('text-anchor', if mirrored then 'end' else 'start')
                     .attr("dx", dx)

    this

export class Orbits
  # View of the solar system from above

  (@orbiters, @data, @container, @options = {}) ->
    console.log "Create orbits"
    @init()

  init: ->
    console.log "Initialize orbits", @data, @options

    @margin = {
      top: 30,
      right: 20,
      bottom: 30,
      left: 60
    }
    
    @extremum = 1.11 * d3.max(@data, (d) ->
      Math.max(Math.abs(d.x), Math.abs(d.y))
    )

    @xScale = d3.scaleLinear().domain([-1 * @extremum, @extremum])
    @yScale = d3.scaleLinear().domain([-1 * @extremum, @extremum])

    @xAxis = d3.axisBottom().ticks(10)
    @yAxis = d3.axisLeft().ticks(10)

    @svg = d3.select(@container).append('svg')

    @plotWrapper = @svg.append('g')
    @plotWrapper.attr('transform', 'translate(' + @margin.left + ',' + @margin.top + ')')

    @plotWrapper.append('g').classed('x axis', true)
    @plotWrapper.append('g').classed('y axis', true)

    @sun = @plotWrapper.append("svg:circle")
    @sun.append('svg:title').text("Sol")
    @sun.attr("r", 17).style("fill", "yellow")

    for slug, config of @orbiters
      @initOrbiter(slug, config)

    @resize()

  orbitersElements: {}
  initOrbiter: (slug, config) ->
    if slug of @orbitersElements then throw new Error("Second init of #{slug}")

    # The order is important, as it will define the default z-order
    orbit_ellipse = @plotWrapper.append("svg:ellipse")
                                .classed('orbit orbit_ellipse', true)
    orbiter = @plotWrapper.append("svg:image")
                          .attr('xlink:href', config['img'])
                          .attr('width', '32px').attr('height', '32px')

    orbit_line = d3.line()
                   .x((d) ~> @xScale(d.x))
                   .y((d) ~> @yScale(d.y))

    orbit_section = @plotWrapper.append('path')
                                .datum(@data)  # fixme
                                .classed('orbit orbit_section', true)

    @orbitersElements[slug] =
      orbiter: orbiter
      orbit_ellipse: orbit_ellipse
      orbit_section: orbit_section
      orbit_line: orbit_line

    this

  resize: ->
    width = jQuery(@container).width() - @margin.left - @margin.right
    height = 1.0 * width

    console.log("Resize orbits : #{width} x #{height}")

    @xScale.range([0, width]);
    @yScale.range([height, 0]);

    @svg.attr('width',  width + @margin.right + @margin.left)
        .attr('height', height + @margin.top + @margin.bottom)

    @sun.attr("cx", width / 2).attr("cy", height / 2)

    for slug, config of @orbiters
      @resizeOrbiter(slug, config)

    @xAxis.scale(@xScale)
    @yAxis.scale(@yScale)

    @svg.select('.x.axis')
        .attr('transform', 'translate(0,' + height + ')')
        .call(@xAxis)

    @svg.select('.y.axis')
        .call(@yAxis)

    this

  resizeOrbiter: (slug, config) ->
    width = jQuery(@container).width() - @margin.left - @margin.right
    height = 1.0 * width

    console.log("Resize orbiter #{slug}")

    el = @orbitersElements[slug]
    el['orbit_section'].attr('d', el['orbit_line'])

    a = config['orbit']['a']
    b = config['orbit']['b']
    c = Math.sqrt(a*a - b*b)
    cx = (width / 2) - c
    cy = (height / 2)
    @yScale.range([0, height])
    el['orbit_ellipse'].attr('cx', cx).attr('cy', cy)
        .attr('rx', @xScale(a) - @xScale(0))
        .attr('ry', @yScale(b) - @yScale(0))
        .attr('transform', 'rotate(66,'+(cx+c)+', '+cy+')')
    @yScale.range([height, 0])

    data = @data

    el['orbiter'].attr('x', @xScale(data[data.length - 1].x) - 16)
    el['orbiter'].attr('y', @yScale(data[data.length - 1].y) - 16)

    this