# 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 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 """©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 @orbiter = null # our Orbiter defined below @time_series = [] # 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 one year ago to three weeks ahead. # We set the h/m/s to zero to benefit from a daily cache. # If you edit these values you need to change the cache warmup too (run.py) started_at = moment().subtract(1, 'year').hours(0).minutes(0).seconds(0) stopped_at = moment().add(3, 'week').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_slug) url = url.replace('', started_at) url = url.replace('', 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) url = url.replace('', started_at.format(API_TIME_FORMAT)) url = url.replace('', 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) url = url.replace('', parameters) url = url.replace('', started_at.format(API_TIME_FORMAT)) url = url.replace('', 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?.showOrbiter target_slug this disableTarget: (target_slug) -> @time_series.forEach((ts) -> ts.hide() if ts.target.slug == target_slug) @targets[target_slug].active = false @orbits?.hideOrbiter target_slug this resize: -> @orbits?.resize(); @time_series.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 = { '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, } )) ) @time_series.forEach((ts) ~> # returning true may be faster ts.options['onMouseOver'] = ~> @time_series.forEach((ts2) -> ts2.showCursor()) ; true 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 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)) @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 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: -> @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: , y: } @init() toString: -> "#{@title} of #{@target.name}" init: -> console.info "Initializing plot of #{@}…" # pre-compute extents for performance when zooming # those 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'] @margin = { top: 30, right: 20, bottom: 30, left: 80 } @xScale = d3.scaleTime().domain(@xDataExtent) @yScale = d3.scaleLinear().domain(@yDataExtent) formatMillisecond = d3.timeFormat(".%L") formatSecond = d3.timeFormat(":%S") formatMinute = d3.timeFormat("%I:%M") formatHour = d3.timeFormat("%I:%M") formatDay = d3.timeFormat("%a %d") formatWeek = d3.timeFormat("%b %d") formatMonth = d3.timeFormat("%B") formatYear = d3.timeFormat("%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) @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 / 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)) @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]) @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: -> 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 %H:%M") 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…" # 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(); @resize(true) this showOrbiter: (slug) -> if not @data[slug].length 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) -> width = Math.ceil($(@container).width() - @margin.left - @margin.right) height = Math.ceil(1.0 * width) console.debug("Resizing orbits : #{width} × #{height}…") 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 - 16).attr("y", height / 2 - 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 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 then return el['orbit_section'].datum(data) el['orbit_section'].attr('d', el['orbit_line']) resetZoom: -> for slug, config of @orbiters el = @orbitersElements[slug] if not @data[slug].length then return el['orbit_section'].datum(@data[slug]) el['orbit_section'].attr('d', el['orbit_line'])