# DEPRECATED
# Use web/static/js/main.js instead.















# 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 in `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 two months ago, and ending in a month.
  (both at midnight)
  """

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

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

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) ~>
      @addTarget(new Target(target_config.slug, target_config.name, target_config))

    @parameters = {}
    @configuration['parameters'].forEach (p) ~>
      @parameters[p['id']] = p

    @orbits = null     # an Orbiter instance (defined below)
    @time_series = []  # a List of TimeSeries instances

  init: (started_at, stopped_at) ->
    """
    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)
    """
    # We set the h/m/s to zero to ensure we benefit from the daily cache.
    started_at = moment(started_at).hours(0).minutes(0).seconds(0)
    stopped_at = moment(stopped_at).hours(0).minutes(0).seconds(0)
    @setStartAndStop(started_at, stopped_at)
    @loadAndCreatePlots(started_at, stopped_at)
    window.addEventListener 'resize', ~> @resize()
    this

  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

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

  buildSampName: ->
    [started_at, stopped_at] = @getDomain()
    targets = [t.name for t in @getEnabledTargets()].sort().join(', ')
    "Heliopropa for #{targets} from #{started_at.format(API_TIME_FORMAT)} to #{stopped_at.format(API_TIME_FORMAT)}."

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

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

  getEnabledTargets: ->
    [target for slug, target of @targets when target.active]

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

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

  resize: ->
    @orbits?.resize()
    @time_series.forEach((ts) -> ts.resize())
    this

  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.utcParse('%Y-%m-%dT%H:%M:%S%Z')
        data = { 'hee': [] }
        configuration['parameters'].forEach((parameter) ->
          data[parameter['id']] = []
        )
        unless csv then reject 'invalid'
        unless csv.length then reject 'empty'
        csv.forEach((d) ->
          dtime = timeFormat(d['time'])
          configuration['parameters'].forEach((parameter) ->
            id = parameter['id']
            val = parseFloat(d[id])
            if not isNaN(val)
              data[id].push({x: dtime, y: val})
          )
          if d['xhee'] and d['yhee']
            data['hee'].push({
              t: dtime, x: parseFloat(d['xhee']), y: parseFloat(d['yhee'])
            })
        )
        resolve data
      )
    )

  loadAndCreatePlots: (started_at, stopped_at) ->
    """
    started_at: moment(.js) datetime object
    stopped_at: moment(.js) datetime 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)

    targets = [@targets[k] for k of @targets]
    targets.forEach((target) ~>
      targetButton = $(".targets-filters .target.#{target.slug}")
      targetButton.addClass('loading')
      targetButton.removeClass('failed error empty')
    )
    handleTarget = (i) ~>
      if i >= targets.length then return
      target = targets[i]
      console.info "Loading CSV data of #{target.name}…"
      targetButton = $(".targets-filters .target.#{target.slug}")
      @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['hee'])
          targetButton.removeClass('loading')
          if target.active then @hideLoader() else @disableTarget(target.slug)
          handleTarget(i+1)
        ,
        (error) ~>
          switch error
            case 'invalid'
              console.error("Failed loading CSV data of #{target.name}.")
              # Sometimes, AMDA's API returns garbage, so the CSV sometime fails
              # But when we re-generate it a second time, usually it's okay.
              # alert("There was an error with #{target.name}.\nPlease retry in a few moments.")
              targetButton.addClass('error')
#              @is_invalid = true
              break
            case 'empty'
              msg = "No data for #{target.name}\n during interval from \n#{started_at} to #{stopped_at}."
              console.warn(msg)
              targetButton.addClass('empty')
#              alert(msg)
              break
          targetButton.addClass('failed')
          targetButton.removeClass('loading')
          @hideLoader()
          handleTarget(i+1)
      )
    handleTarget(0)
    this

  clearPlots: ->
    @orbits.clear()
    @time_series.forEach((ts) -> ts.clear())
    @orbits = null
    @time_series = []  # 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)
      console.log(target['name'], id, data[id])
      if data[id].length
        @time_series.push(new TimeSeries(
          id, title, target, data[id], @parameters[id].active, container, {
            'started_at': @started_at,
            'stopped_at': @stopped_at,
          }
        ))
    )
    # Let's override all time series' input handlers to link them together
    @time_series.forEach((ts) ~>  # returning true may be faster, how to bench?
      ts.options['onMouseOver'] = ~>
        true  # let's do nothing, we'll show the cursor during moveCursor()
      ts.options['onMouseOut'] = ~>
        @time_series.forEach((ts2) -> ts2.hideCursor()) ; true
      ts.options['onMouseMove'] = (t) ~>
        @time_series.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
    )
    @time_series

  getEnabledParameters: ->
    [p for slug, p in @parameters when p.active]

  enableParameter: (parameter_slug) ->
    if parameter_slug not of @parameters then console.error("Unknown parameter #{parameter_slug}.")
    @parameters[parameter_slug].active = true
    @time_series.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
    @time_series.forEach((ts) -> ts.hide() if ts.parameter == parameter_slug)
    this

  showCatalogLayer: (catalog_slug) ->
    @time_series.forEach((ts) -> ts.showCatalogLayer(catalog_slug))
    this

  hideCatalogLayer: (catalog_slug) ->
    @time_series.forEach((ts) -> ts.hideCatalogLayer(catalog_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 (not @is_invalid) and
    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.
      #@time_series.forEach((ts) -> if not ts.visible then ts.zoomIn(started_at, stopped_at))

      tsv = @time_series.filter((ts) -> ts.visible)
      tsv_cursor = 0
      tsv_length = tsv.length
      zoomedOnVisible = new Promise((resolve, reject) ->

        tsv_zoom_on_next = (i) ->
          if i >= tsv_length
            resolve()
            return
          ts = tsv[i]
          ts.zoomIn(started_at, stopped_at)
            .then(-> tsv_zoom_on_next(i+1))
        tsv_zoom_on_next(0)

      )

      zoomedOnVisible.then(->
        @time_series.forEach((ts) -> if not ts.visible then ts.zoomIn(started_at, stopped_at))
      )

#      @time_series.forEach((ts) -> if     ts.visible then ts.zoomIn(started_at, stopped_at))
      @orbits.resizeDomain started_at, stopped_at
    else
#      @is_invalid = false
      console.info "Resizing the temporal domain from #{formatted_started_at} to #{formatted_stopped_at} and fetching new data…"
      console.warn "This might take a good while… Why not see what else we're up to on http://cdpp.eu while you're waiting?"
      @clearPlots()
      @loadAndCreatePlots(started_at, stopped_at)

    this

  resetZoom: ->
    @time_series.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 btan 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>}
    # options: object with the following properties
    #          started_at (Moment obj)
    #          stopped_at (Moment obj)
    now = moment()
    @predictiveData = [d for d in @data when moment(d.x) >= now]
    @init()

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

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

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

    [width, height] = @recomputeDimensions()

    # Pre-compute extents for performance when zooming.
    # These are final and always hold the biggest extent.
    @xDataExtent = d3.extent(@data, (d) -> d.x)
    @yDataExtent = d3.extent(@data, (d) -> d.y)
    if @options['started_at'] then @xDataExtent[0] = @options['started_at']
    if @options['stopped_at'] then @xDataExtent[1] = @options['stopped_at']

    # https://github.com/d3/d3-scale/blob/master/src/utcTime.js
    # scaleUtc collides with our custom multiFormat ticks
#    @xScale = d3.scaleUtc().domain(@xDataExtent)
    @xScale = d3.scaleTime().domain(@xDataExtent)

    # Domain on a log scale MUST NOT cross zero
#    @yScale = d3.scaleLog().domain(@yDataExtent)
    @yScale = d3.scaleLinear().domain(@yDataExtent)

    formatMillisecond = d3.utcFormat(".%L")
    formatSecond = d3.utcFormat(":%S")
    formatMinute = d3.utcFormat("%H:%M")
    formatHour = d3.utcFormat("%H:%M")
    formatDay = d3.utcFormat("%a %d")
    formatWeek = d3.utcFormat("%b %d")
    formatMonth = d3.utcFormat("%B")
    formatYear = d3.utcFormat("%Y")

    multiFormat = (date) ->
      if date > d3.timeSecond(date) then return formatMillisecond(date)
      if date > d3.timeMinute(date) then return formatSecond(date)
      if date > d3.timeHour(date)   then return formatMinute(date)
      if date > d3.timeDay(date)    then return formatHour(date)
      if date > d3.timeMonth(date)
        if date > d3.timeWeek(date) then return formatDay(date)
        else return formatWeek(date)
      if date > d3.timeYear(date)   then return formatMonth(date)
      return formatYear(date)

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

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

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

    @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)
    @predictiveDataPath = @pathWrapper.append('path')
                                      .datum(@predictiveData)
                                      .classed('predictive-line', true)

    @createCatalogLayers()

    @horizontalLines = []
    if @options['horizontalLines']
      for line in @options['horizontalLines']
        lineElement = @svg.append("line")
                          .attr("class", "line horitonal-line")
                          .style("stroke", "orange")  # move to CSS
                          .style("stroke-dasharray", ("3, 2"))  # idem
        @horizontalLines.push({'element': lineElement, 'config': line})

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

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

    #console.debug "Creating the zooming brush for #{@}…"
    # Note that 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.
    @brushOverlay = @svg.select(".brush .overlay")
    @brushOverlay
        .on("mouseover.swapp", @onMouseOver)
        .on("mouseout.swapp",  @onMouseOut)
        .on("mousemove.swapp", @onMouseMove)
        .on("dblclick.swapp",  @onDoubleClick)

    @resize()

  recomputeDimensions: ->
    width  = Math.ceil($(@container).width() - @margin.left - @margin.right)
    height = Math.ceil(RATIO * width)
    @plotWidth = width
    @plotHeight = height
    [width, height]

  RATIO = Math.pow(GOLDEN_RATIO, 4)
  resize: ->
    [width, height] = @recomputeDimensions()

    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)
    @predictiveDataPath.attr('d', @line)

    for line in @horizontalLines
      lineValue = @yScale(line['config']['value']) + @margin.top
      line['element'].attr("x1", @margin.left)
                     .attr("y1", lineValue)
                     .attr("x2", @margin.left + width)
                     .attr("y2", lineValue)

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

    #if width < 600 then @xAxis.ticks(3) else @xAxis.ticks(7, ",f")
    @xAxis.ticks(Math.floor(width / 80.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.0))

    @resizeCatalogLayers()

    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(@brushOverlay.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])
    @yScale.domain(d3.extent(@data, (d) -> if startDate <= d.x <= stopDate then d.y else 0))
    @applyZoom()

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

  applyZoom: ->
    duration = 0
    if @visible
      duration = 750
      console.debug("Applying zoom to visible #{@}…")
      t = @svg.transition().duration(duration)
      @svg.select('.x.axis').transition(t).call(@xAxis)
      @svg.select('.y.axis').transition(t).call(@yAxis)
      @path.transition(t).attr('d', @line)
      @predictiveDataPath.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)
      @predictiveDataPath.attr('d', @line)
    @resizeCatalogLayers()
    @hideCursor()
    new Promise((resolve, reject) ->
        if 0 == duration
            resolve()
        else
            setTimeout((-> resolve()), duration+50)
    )


  createCatalogLayers: ->
    @layers_rects = {}
    for catalog_slug, layers of @target.config.layers
      #console.debug("Creating layers of #{catalog_slug}…", layers)
      @layers_rects[catalog_slug] = []
      for layer in layers
        started_at = moment(layer.start)
        stopped_at = moment(layer.stop)
        #console.debug(started_at, stopped_at)
        #console.debug(layer.start, layer.stop)
        @layers_rects[catalog_slug].push(
          @createCatalogLayer(started_at, stopped_at)
        )
      @hideCatalogLayer(catalog_slug)
    this

  createCatalogLayer: (started_at, stopped_at) ->
    layer_rect = @pathWrapper.append("rect")
                             .attr('y', 0)
                             .attr('height', @plotHeight)
                             .attr('fill', '#FFFD64C2')
    # ↓ Not triggered, mouse events are captured before they reach this rect
    #layer_rect.append('svg:title').text("I AM TEXT")
    layer_rect

  resizeCatalogLayers: ->
    for catalog_slug, layers of @target.config.layers
      #console.debug("Resizing layers of #{catalog_slug}…", layers)
      for layer, i in layers
        started_at = moment(layer.start)
        stopped_at = moment(layer.stop)
        width = Math.max(2, @xScale(stopped_at) - @xScale(started_at))
        @layers_rects[catalog_slug][i].attr('x', @xScale(started_at))
                                      .attr('width', width)
    this

  showCatalogLayer: (catalog_slug) ->
    for layer, i in @target.config.layers[catalog_slug]
      @layers_rects[catalog_slug][i].style("display", null)
    this

  hideCatalogLayer: (catalog_slug) ->
    for layer, i in @target.config.layers[catalog_slug]
      @layers_rects[catalog_slug][i].style("display", "none")
    this

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

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

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

  moveCursor: (x0) ->
    i = @bisectDate(@data, x0, 1)
    d0 = @data[i - 1]
    d1 = @data[i]
    if (not d1) or (not d0)
      @hideCursor()
      return

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

    this


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

export class Orbits
  """
  View of the solar system from above, with orbits segments for selected time
  interval, from real data.
  """

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

    console.log "Initializing plot of orbits…"

    # In the variable names below, x and y are the usual ones.
    # (on the plots we show Y on the x axis, and X reversed on the y axis)

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

    @data = {}  # slug => HEE array
    @orbiters = {}  # slug => config
    @orbitersElements = {}  # see initOrbiter
    @orbitersExtrema = {}  # slug => local extrema
    @lastOrbiterData = {}  # slug => most recently used datum for position
    @xScale = d3.scaleLinear().domain([-1, 1])
    @yScale = d3.scaleLinear().domain([1, -1])

    @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('Y')
    # 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('X')
    @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("Sun")

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

  initOrbiter: (slug, config, data) ->
    @data[slug] = data
    @orbiters[slug] = config

    if data.length
      console.info "Initializing orbit of #{config.name}…"
    else
      console.warn "No orbit data for #{config.name}…"
      return

    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')
    orbiter.append('svg:title').text(config.name)

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

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

    @orbitersElements[slug] =
      orbiter: orbiter
      orbit_ellipse: orbit_ellipse
      orbit_section: orbit_section
      orbit_line: orbit_line
    @orbitersExtrema[slug] = d3.max(data, (d) ->
      Math.max(Math.abs(d.x), Math.abs(d.y))
    )

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

    if config.active
      @enableTarget slug
    else
      @disableTarget slug

    this

  enableTarget: (slug) ->
    @orbiters[slug].enabled = true
    @showOrbiter slug

  disableTarget: (slug) ->
    @orbiters[slug].enabled = false
    @hideOrbiter slug

  showOrbiter: (slug) ->
    if not @data[slug].length then return
    if not @orbiters[slug].enabled then return
    @orbiters[slug].hidden = false
    @orbitersElements[slug].orbiter.style("display", null)
    @orbitersElements[slug].orbit_ellipse.style("display", null)
    @orbitersElements[slug].orbit_section.style("display", null)
    @resize(true)

  hideOrbiter: (slug) ->
    if not @data[slug].length then return
    @orbiters[slug].hidden = true
    @orbitersElements[slug].orbiter.style("display", "none")
    @orbitersElements[slug].orbit_ellipse.style("display", "none")
    @orbitersElements[slug].orbit_section.style("display", "none")
    @resize(true)

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

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

    console.debug("Resizing orbits : #{width} × #{height}…")

    if extremum == null
      extremum = 1.1 * d3.max(
        [@orbitersExtrema[s] for s, o of @orbiters when not o.hidden]
      )
#    extremum = 1.1 * d3.max([s for s, o of @orbiters when not o.hidden], (d) ~>
#      @orbitersExtrema[d]
#    )
    @xScale = d3.scaleLinear().domain([-1 * extremum, extremum])
    @yScale = d3.scaleLinear().domain([extremum, -1 * extremum])

    @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.0 - 16).attr("y", height / 2.0 - 16)

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

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

    @svg.select('.x.axis').attr('transform', 'translate(0,' + height + ')')
    if animate
      t = @svg.transition().duration(750)
      t1 = @svg.transition().duration(4750)
      @svg.select('.x.axis').transition(t).call(@xAxis);
      @svg.select('.y.axis').transition(t).call(@yAxis);
    else
      @svg.select('.x.axis').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, animate = false) ->
    data = @data[slug]
    if not data.length then return
    console.debug("Resizing orbit of #{slug}…")

    tt = @svg.transition().duration(750)
    el = @orbitersElements[slug]
    orbit_section = el['orbit_section']
    if animate
      t = @svg.transition().duration(750)
      orbit_section = orbit_section.transition(tt)
    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)

    orbit_ellipse = el['orbit_ellipse']
    if animate
      t = @svg.transition().duration(750)
      orbit_ellipse = orbit_ellipse.transition(t)
    # These ellipses ain't worth much
    # Maybe a simple circle whose radius is the mean radius of the orbit ?
    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+')')

    @repositionOrbiter(slug, null, true)

    this

  zoomToTarget: (slug) ->
    @resize(true, 1.1 * @orbitersExtrema[slug])

  repositionOrbiter: (slug, datum, animate = false) ->
    data = @data[slug]
    if not data.length then return
    datum ?= @lastOrbiterData[slug]
    datum ?= data[data.length - 1]
    @lastOrbiterData[slug] = datum
    el = @orbitersElements[slug]['orbiter']
    if animate
      t = @svg.transition().duration(750)
      el = el.transition(t)
    el.attr('x', @xScale(datum.y) - 16)
    el.attr('y', @yScale(datum.x) - 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
      if not data.length
        @hideOrbiter(slug)
        continue
      el['orbit_section'].datum(data)
      el['orbit_section'].attr('d', el['orbit_line'])
      @showOrbiter(slug)

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