# Livescript transpiles to javascript, and is easier on the eyes and brain.
# Get the `lsc` binary from here : http://livescript.net
# It is quite close to Python, syntax-wise, and full of sugar.

# To transpile this file to javascript, and generate `swapp.js` :
# $ lsc --compile swapp.ls
# You can have it watch for changes and transpile automatically :
# $ lsc --watch --compile swapp.ls

# All the "javascript" code is in this file, except for inline scripts in
# templates, such as `home.html.jinja2`.

# Note: We use Promises and ES6 whenever relevant.
# You also will need d3js v4 documentation : https://d3js.org/
# We're using a custom build of 4.9.1, one line changed, see d3-custom.js
# Event bubbling cannot trigger two rects unless we make an event dispatcher,
# and d3's brush is stopping propagation, as it should by default.

###############################################################################

const GOLDEN_RATIO = 2 / (1 + Math.sqrt(5))  # Between 0 and 1 (0.618...)

###############################################################################

class Target
  (@slug, @name, @config) ->
    @active = @config.active

###############################################################################

export class SpaceWeather
  """
  The main app, instanciated from an inline script.
  It defaults to an interval starting a year ago, and ending in seven days.
  (both at midnight)
  """

  API_TIME_FORMAT = "YYYY-MM-DDTHH:mm:ss"
  INPUT_TIME_FORMAT = "YYYY-MM-DD"

  (@configuration) ->
    console.info """
  _   _      _ _       ____
 | | | | ___| (_) ___ |  _ \\ _ __ ___  _ __   __ _
 | |_| |/ _ \\ | |/ _ \\| |_) | '__/ _ \\| '_ \\ / _` |
 |  _  |  __/ | | (_) |  __/| | | (_) | |_) | (_| |
 |_| |_|\\___|_|_|\\___/|_|_  |_|_ \\___/| .__/ \\__,_|
 | |__  _   _   / ___|  _ \\|  _ \\|  _ \\_|
 | '_ \\| | | | | |   | | | | |_) | |_) |
 | |_) | |_| | | |___| |_| |  __/|  __/
 |_.__/ \\__, |  \\____|____/|_|   |_|
        |___/

The full source of this website is available at :
https://gitlab.irap.omp.eu/CDPP/SPACEWEATHERONLINE
"""  # HelioPropa by CDPP (mushed 'cause we need to escape backslashes)
    @targets = {}
    configs = [@configuration.targets[k] for k of @configuration.targets]
    configs.forEach((target_config) ~>
      @targets[target_config.slug] = new Target(target_config.slug, target_config.name, target_config)
    )
    @parameters = {}
    @configuration['parameters'].forEach((p) ~>
        @parameters[p['id']] = p
    )
    @orbiter = null   # our Orbiter
    @timeSeries = []  # a List of TimeSeries objects

  init: ->
    """
    This is called by the inline bootstrap javascript code.
    This ain't in the constructor because it might return a Promise later on.
    (for the loader, for example)
    """
    # Default time interval is from two weeks ago to one week ahead.
    # We set the h/m/s to zero to benefit from a daily cache.
    started_at = moment().subtract(1, 'year').hours(0).minutes(0).seconds(0)
    stopped_at = moment().add(1, 'week').hours(0).minutes(0).seconds(0)
    @setStartAndStop(started_at, stopped_at)
    @loadAndCreatePlots(started_at, stopped_at)

    window.addEventListener 'resize', ~> @resize()

  buildDataUrlForTarget: (target_slug, started_at, stopped_at) ->
    url = @configuration['api']['data_for_interval']
    url = url.replace('<target>', target_slug)
    url = url.replace('<started_at>', started_at)
    url = url.replace('<stopped_at>', stopped_at)
    url

  buildDownloadUrl: ->
    [started_at, stopped_at] = @getDomain()
    targets = [t for t of @targets when @targets[t].active].sort().join('-')
    url = @configuration['api']['download']
    url = url.replace('<targets>', targets)
    url = url.replace('<started_at>', started_at.format(API_TIME_FORMAT))
    url = url.replace('<stopped_at>', stopped_at.format(API_TIME_FORMAT))
    url

  addTarget: (target) ->
    @targets[target.slug] = target
    this

#  enableAllTargets: ->
#    for slug, target of @targets
#      enableTarget(slug)
#    this

  enableTarget: (target_slug) ->
    @timeSeries.forEach((ts) ~> ts.show() if ts.target.slug == target_slug && @parameters[ts.parameter].active)
    @targets[target_slug].active = true
    this

  disableTarget: (target_slug) ->
    @timeSeries.forEach((ts) -> ts.hide() if ts.target.slug == target_slug)
    @targets[target_slug].active = false
    this

  resize: ->
    @orbits?.resize();
    @timeSeries.forEach((ts) -> ts.resize())

  showLoader: ->
    $("\#plots_loader").show();

  hideLoader: ->
    $("\#plots_loader").hide();

  loadData: (target_slug, started_at, stopped_at) ->
    """
    Load the data as CSV for the specified target and interval,
    and return it in a Promise.
    """

    sw = this
    new Promise((resolve, reject) ->
      url = sw.buildDataUrlForTarget(target_slug, started_at, stopped_at)
      d3.csv(url, (csv) ->
        console.debug("Requested CSV for #{target_slug}...", csv)
        timeFormat = d3.timeParse('%Y-%m-%dT%H:%M:%S%Z')
        data = { 'hci': [] }
        configuration['parameters'].forEach((parameter) ->
          data[parameter['id']] = []
        )
        unless csv then reject "CSV is empty or nonexistent at URL '#{url}'."
        unless csv.length then reject "CSV is empty at '#{url}'."
        csv.forEach((d) ->
          dtime = timeFormat(d['time'])
          configuration['parameters'].forEach((parameter) ->
            id = parameter['id']
            data[id].push({x: dtime, y: parseFloat(d[id])})
          )
          if d['xhci'] and d['yhci']
            data['hci'].push({
              t: dtime, x: parseFloat(d['xhci']), y: parseFloat(d['yhci'])
            })
        )
        resolve data
      )
    )

  loadAndCreatePlots: (started_at, stopped_at) ->
    """
    started_at: moment(.js) object
    stopped_at: moment(.js) object
    """
    @showLoader()
    @started_at = started_at
    @stopped_at = stopped_at
    @orbits = new Orbits(@configuration.orbits_container, @configuration)
    started_at = started_at.format(API_TIME_FORMAT)
    stopped_at = stopped_at.format(API_TIME_FORMAT)
    # active_targets = [@targets[k] for k of @targets when @targets[k].active]
    [@targets[k] for k of @targets].forEach((target) ~>
      console.info "Loading CSV data of #{target.name}…"
      targetButton = $(".targets-filters .target.#{target.slug}")
      targetButton.addClass('loading')
      targetButton.removeClass('failed')
      @loadData(target.slug, started_at, stopped_at).then(
        (data) ~>
          console.info "Loaded CSV data of #{target.name}.", data
          @createTimeSeries(target, data)
          @orbits.initOrbiter(target.slug, target.config, data['hci'])
          targetButton.removeClass('loading')
          if target.active then @hideLoader() else @disableTarget(target.slug)
        ,
        (error) ~>
          # Sometimes, AMDA's API returns garbage, so the CSV sometime fails
          # But when we re-generate it a second time, usually it's okay.
          console.error("Failed loading CSV data of #{target.name}.", error)
          alert("There was an error with #{target.name}.\nPlease retry.")
          targetButton.addClass('failed')
          targetButton.removeClass('loading')
          @hideLoader()
      )
    )

  clearPlots: ->
    @orbits.clear()
    @timeSeries.forEach((ts) -> ts.clear())
    @orbits = null
    @timeSeries = []  # do we de-reference everything ? listeners ? #memleak?
    this

  createTimeSeries: (target, data) ->
    @configuration['parameters'].forEach((parameter) ~>
      container = @configuration['time_series_container']
      id = parameter['id'] ; title = parameter['title']
      if id not of data then console.error("No data for id '#{id}'.", data)
      @timeSeries.push(new TimeSeries(
        id, title, target, data[id], @parameters[id].active, container
      ))
    )
    @timeSeries.forEach((ts) ~>  # returning true may be faster
      ts.options['onMouseOver'] = ~>
        @timeSeries.forEach((ts2) -> ts2.showCursor()) ; true
      ts.options['onMouseOut'] = ~>
        @timeSeries.forEach((ts2) -> ts2.hideCursor()) ; true
      ts.options['onMouseMove'] = (t) ~>
        @timeSeries.forEach((ts2) -> ts2.moveCursor(t))
        @orbits?.moveToDate(t) ; true
      ts.options['onBrushEnd'] = (sta, sto) ~>
        @resizeDomain(moment(sta), moment(sto)) ; true
      ts.options['onDblClick'] = ~>
        @resetZoom() ; $("\#zoom_controls_help")?.remove() ; true
    )
    @timeSeries

  enableParameter: (parameter_slug) ->
    if parameter_slug not of @parameters then console.error("Unknown parameter #{parameter_slug}.")
    @parameters[parameter_slug].active = true
    @timeSeries.forEach((ts) ~> ts.show() if ts.parameter == parameter_slug && @targets[ts.target.slug].active)
    this

  disableParameter: (parameter_slug) ->
    if parameter_slug not of @parameters then console.error("Unknown parameter #{parameter_slug}.")
    @parameters[parameter_slug].active = false
    @timeSeries.forEach((ts) -> ts.hide() if ts.parameter == parameter_slug)
    this

  getDomain: ->
    if @current_started_at? and @current_stopped_at?
      return [@current_started_at, @current_stopped_at]
    return [@started_at, @stopped_at]

  resizeDomain: (started_at, stopped_at) ->
    if stopped_at < started_at
      [started_at, stopped_at] = [stopped_at, started_at]
    if started_at == stopped_at
      console.warn "Please provide distinct start and stop dates."
      return
    max_stopped_at = started_at.clone().add(2, 'years')
    if stopped_at > max_stopped_at
      console.warn "The time interval was truncated beacuse it was bigger than two years."
      stopped_at = max_stopped_at

    @setStartAndStop(started_at, stopped_at)
    formatted_started_at = started_at.format()
    formatted_stopped_at = stopped_at.format()

    if (@started_at <= started_at <= @stopped_at) and
       (@started_at <= stopped_at <= @stopped_at) then
      console.info "Resizing the temporal domain from #{formatted_started_at} to #{formatted_stopped_at} without fetching new data…"
      # We first resize the hidden time series and only afterwards we resize
      # the visible ones, for a smoother transition.
      @timeSeries.forEach((ts) -> if not ts.visible then ts.zoomIn(started_at, stopped_at))
      @timeSeries.forEach((ts) -> if     ts.visible then ts.zoomIn(started_at, stopped_at))
      @orbits.resizeDomain started_at, stopped_at
    else
      console.info "Resizing the temporal domain from #{formatted_started_at} to #{formatted_stopped_at} and fetching new data…"
      console.warn "This might take a while… Why not see what else we're up to on http://cdpp.eu while you're waiting?"
      # fetch new data and remake the plots
      @clearPlots()
      @loadAndCreatePlots(started_at, stopped_at)

    this

  resetZoom: ->
    @timeSeries.forEach((ts) -> ts.resetZoom())
    @orbits.resetZoom()
    @setStartAndStop(@started_at, @stopped_at)
    this

  setStartAndStop: (started_at, stopped_at) ->
    console.info "Setting time interval from #{started_at} to #{stopped_at}…"
    @current_started_at = started_at
    @current_stopped_at = stopped_at
    $("\#started_at").val(started_at.format(INPUT_TIME_FORMAT))
    $("\#stopped_at").val(stopped_at.format(INPUT_TIME_FORMAT))
    this



###############################################################################
###############################################################################


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

  (@parameter, @title, @target, data, @visible, @container, @options = {}) ->
    # parameter : slug of the parameter to observe, like magn or pdyn
    # title : string, more descriptive, shown on the left of the Y axis
    # target : target object, like described in configuration
    # data : list of {x: <datetime>, y: <float>}
    @setData(data)
    @init()

  toString: -> "#{@title} of #{@target.name}"

  setData: (data) ->
    @data = data  # and pre-compute extents for performance when zooming
    @xDataExtent = d3.extent(@data, (d) -> d.x)
    @yDataExtent = d3.extent(@data, (d) -> d.y)

  init: ->
    console.info "Initializing plot of #{@}…"

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

    @xScale = d3.scaleTime().domain(@xDataExtent)
    @yScale = d3.scaleLinear().domain(@yDataExtent)

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

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

    @svg = d3.select(@container).append('svg')
    @svg.attr("class", "#{@parameter} #{@target.slug}")

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

    clipId = "ts-clip-#{@parameter}-#{@target.slug}"
    @clip = @svg.append("defs").append("svg:clipPath")
                .attr("id", clipId)
                .append("svg:rect")
                .attr("x", 0)
                .attr("y", 0)

    @pathWrapper = @plotWrapper.append('g')
    @pathWrapper.attr("clip-path", "url(\##{clipId})")
    @path = @pathWrapper.append('path')
                        .datum(@data)
                        .classed('line', true)

    @brush = @plotWrapper.append("g")
                         .attr("class", "brush")

    # deprecated, use brush's 'overlay' child
    @mouseCanvas = @plotWrapper.append("rect")
                               .style("fill", "none")

    @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)
    @yAxisTextTarget = @plotWrapper.append("text")
                                   .attr("transform", "rotate(-90)")
                                   .attr("dy", "1em")
                                   .style("text-anchor", "middle")
                                   .style("font-style", "oblique")
                                   .text(@target.name)

    @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()

  RATIO = GOLDEN_RATIO * GOLDEN_RATIO * GOLDEN_RATIO * GOLDEN_RATIO
  resize: ->
    width  = Math.ceil($(@container).width() - @margin.left - @margin.right)
    height = Math.ceil(RATIO * width)

    @plotWidth = width
    @plotHeight = height

    console.debug("Resizing #{@}: #{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)

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

    @path.attr('d', @line)

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

    #if width < 600 then @xAxis.ticks(3) else @xAxis.ticks(7, ",f")
    @xAxis.ticks(Math.floor(width / 90.0))  # not working as expected
    @yAxis.ticks(Math.floor(height / 18.0))

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

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

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

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

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

    if not @brushFunction?
      console.debug "Creating the zooming brush for #{@}…"
      # looks like d3.brush handles its own resizing on window.resize
      @brushFunction =
          d3.brushX()
            .extent([[0, 0], [width, height]])
            .handleSize(0)
            .on("end", @onBrushEnd)
#            .on("start", @onBrushStart)
#            .on("move", @onBrushMove)
      @brush.call(@brushFunction)

      # We're also adding our own cursor events to the brush's overlay,
      # because it captures events and a rect cannot contain another.
      @svg.select(".brush .overlay")
          .on("mouseover.swapp", @onMouseOver)
          .on("mouseout.swapp",  @onMouseOut)
          .on("mousemove.swapp", @onMouseMove)
          .on("dblclick.swapp",  @onDoubleClick)

    unless @visible then @hide()
    this

  clear: ->
    $(@svg.node()).remove()
    @visible = false

  show: ->
    $(@svg.node()).show()
    @visible = true

  hide: ->
    $(@svg.node()).hide()
    @visible = false

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

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

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

  onDoubleClick: ~>
    if @options.onDblClick? then @options.onDblClick() else @resetZoom()

  onBrushEnd: ~>
    s = d3.event.selection
    if s
      minmax = [s[0], s[1]].map(@xScale.invert, @xScale)
      @brush.call(@brushFunction.move, null)  # some voodoo to hide the brush
      if @options.onBrushEnd? then @options.onBrushEnd(minmax[0], minmax[1])
                              else @zoomIn(minmax[0], minmax[1])

  zoomIn: (startDate, stopDate) ->
    console.debug "Zooming in #{@} from #{startDate} to #{stopDate}."
    [minDate, maxDate] = @xDataExtent
    if startDate < minDate then startDate = minDate
    if stopDate > maxDate then stopDate = maxDate
    @xScale.domain([startDate, stopDate])
    @applyZoom()

  resetZoom: ->
    @xScale.domain(@xDataExtent)
    @yScale.domain(@yDataExtent)
    @applyZoom()

  applyZoom: ->
    if @visible
      console.debug("Applying zoom to visible #{@}…")
      t = @svg.transition().duration(750)
      @svg.select('.x.axis').transition(t).call(@xAxis);
      @svg.select('.y.axis').transition(t).call(@yAxis);
      @path.transition(t).attr('d', @line)
    else
      console.debug("Applying zoom to hidden #{@}…")
      @svg.select('.x.axis').call(@xAxis);
      @svg.select('.y.axis').call(@yAxis);
      @path.attr('d', @line)

  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)

    mirrored = if @plotWidth? and xx > @plotWidth / 2 then true else false

    dx = 8  # horizontal delta between the dot and the text
    dx = -1 * dx if mirrored

    transform = "translate(#{xx}, #{yy})"
    @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, with orbits segments for selected time
  interval, from real data.
  """

  (@container, @options = {}) ->
    @init()

  init: ->
    console.log "Initializing plot of orbits…"

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

    @data = {}  # slug => HCI array
    @orbiters = {}  # slug => config
    @orbitersElements = {}
    @extremum = 1
    @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 + ')')

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

    @xAxisTitle = @xAxisLine.append('text').attr('fill', '#000')
    @xAxisTitle.style("text-anchor", "middle")
    @xAxisTitle.append('tspan').text('X')
    # No : https://bugzilla.mozilla.org/show_bug.cgi?id=308338
    # @xAxisTitle.append('tspan').attr('baseline-shift', 'sub').text('HEE')
    # Also, don't use em as dy units
    @xAxisTitle.append('tspan').attr('dy', '3px').text('HEE').attr('font-size', '8px')
    @xAxisTitle.append('tspan').attr('dy', '-3px').text('   (AU)')

    @yAxisTitle = @yAxisLine.append('text').attr('fill', '#000')
    @yAxisTitle.style("text-anchor", "middle")
    @yAxisTitle.append('tspan').text('Y')
    @yAxisTitle.append('tspan').attr('dy', '3px').text('HEE').attr('font-size', '8px')
    @yAxisTitle.append('tspan').attr('dy', '-3px').text('   (AU)')
    @yAxisTitle.attr('transform', 'rotate(-90)')

    @sun = @plotWrapper.append("svg:image")
                       .attr('xlink:href', @options.sun.img)
                       .attr('width', '32px').attr('height', '32px')
    @sun.append('svg:title').text("Sol")

    $(@svg.node()).hide();  # we'll show it later when there'll be data
    @resize()

  initOrbiter: (slug, config, data) ->
    console.info "Initializing orbit of #{config.name}…"
    if slug of @orbitersElements then throw new Error("Second init of #{slug}")

    @extremum = Math.max(@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])

    # 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)
                                .classed('orbit orbit_section', true)

    @orbiters[slug] = config
    @data[slug] = data
    @orbitersElements[slug] =
      orbiter: orbiter
      orbit_ellipse: orbit_ellipse
      orbit_section: orbit_section
      orbit_line: orbit_line

    @resize()

    $(@svg.node()).show();

    this

  clear: ->
    $(@svg.node()).remove()

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

    console.debug("Resizing orbits : #{width} × #{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("x", width / 2 - 16).attr("y", height / 2 - 16)

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

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

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

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

    @xAxisTitle.attr("x", width / 2)
               .attr("y", 37)
    @yAxisTitle.attr("x", -1 * height / 2)
               .attr("y", -30)

    this

  resizeOrbiter: (slug, config, width, height) ->
    console.debug("Resizing orbit of #{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[slug]

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

    this

  repositionOrbiter: (slug, datum) ->
    data = @data[slug]
    datum ?= data[data.length - 1]
    el = @orbitersElements[slug]
    el['orbiter'].attr('x', @xScale(datum.x) - 16)
    el['orbiter'].attr('y', @yScale(datum.y) - 16)
    this

  bisectDate: d3.bisector((d) -> d.t).left

  moveToDate: (t) ->
    console.warn("Trying to move to an undefined date !") unless t
    for slug, el of @orbitersElements
      data = @data[slug]
      i = @bisectDate(data, t, 1)
      d0 = data[i - 1]
      d1 = data[i]
      continue unless d1 and d0
      d = if t - d0.t > d1.t - t then d1 else d0
      @repositionOrbiter(slug, d)
    this

  resizeDomain: (started_at, stopped_at) ->
    for slug, config of @orbiters
      el = @orbitersElements[slug]
      data = @data[slug].filter (d) -> started_at <= d.t <= stopped_at
      el['orbit_section'].datum(data)
      el['orbit_section'].attr('d', el['orbit_line'])

  resetZoom: ->
    for slug, config of @orbiters
      el = @orbitersElements[slug]
      el['orbit_section'].datum(@data[slug])
      el['orbit_section'].attr('d', el['orbit_line'])