Files
ember-panda/app/components/project/workflow-group-offset-diagram.coffee

374 lines
18 KiB
CoffeeScript

import Ember from 'ember'
import EmberCPM from 'ember-cpm'
{ Macros: { sum, difference, product } } = EmberCPM
# SOURCES
# http://bl.ocks.org/kiranml1/6872226
# https://bost.ocks.org/mike/bar/2/
# https://bl.ocks.org/hrecht/f84012ee860cb4da66331f18d588eee3
GraphWrapper = Ember.Object.extend
record: null
start: null
finish: null
init: ->
@_super arguments...
@set 'start', value: 0, date: null, has_variable_value_part: false
@set 'finish', value: 0, date: null, has_variable_value_part: false
duration: Ember.computed 'start.value', 'finish.value', ->
@get('finish.value') - @get('start.value')
export default Ember.Component.extend
classNames: ['workflow-group-offset-diagram-component']
bar_height: 20
x_axis_space: 60
left_space: 250
svg_width: 1200
number_of_groups: Ember.computed.alias 'workflow_groups.length'
graph_height: product 'number_of_groups', 'bar_height'
all_workflow_groups: Ember.computed.alias 'active_workflow_groups'
workflow_groups: Ember.computed 'all_workflow_groups.@each.offset_type', 'all_workflow_groups.@each.duration_value', ->
@get('all_workflow_groups').filter((r) -> r.get('offset_type') isnt 'none').sortBy('position')
range_start_default: null
range_start: null
range_end: null
range_bounds: {min: -7, max: 7}
range_formatter: (-> [{to: @days_offset_format.bind(@)}, {to: @days_offset_format.bind(@)}]).property()
#range_formatter: [{to: ((v)->v)}, {to: ((v)->v)}]
reference_date_day_of_week: Ember.computed.oneWay 'project.reference_date_day_of_week'
choose_reference_date: true # choose a specific date to display as planning. Use X with migration date at specific day otherwise
chosen_absolute_date: null
batch_size_simulation_range: Ember.computed 'project.{batch_size_lower_value,batch_size_upper_value}',->
{min: @get('project.batch_size_lower_value'), max: @get('project.batch_size_upper_value')}
batch_size_simulation_value: 0
batch_size_formatter: (-> [to: (v) -> Math.round(v)]).property()
#all_offset_values: Ember.computed.mapBy 'workflow_groups', 'offset_values'
#uniq_offset_values: Ember.computed.uniq 'all_offset_values'
#offset_values: Ember.computed 'uniq_offset_values', -> @get('uniq_offset_values').sort()
#min_offset_value: Ember.computed.alias 'offset_values.firstObject'
#max_offset_value: Ember.computed.alias 'offset_values.lastObject'
#max_end_time: Ember.computed 'workflow_groups.@each.offset_value', 'workflow_groups.@each.duration_value', ->
# max_end_time = -1e-10
# @get('workflow_groups').forEach (workflow_group) ->
# end_time = workflow_group.get('offset_value') + workflow_group.get('duration_value')
# max_end_time = end_time if end_time > max_end_time
# max_end_time
days_offset_format: (v) ->
v = Math.round(v) # could be float from slider
if @get('chosen_absolute_date')
date = moment(@get('chosen_absolute_date'))
date.add(v, 'day') if v
date.format("D-M-YYYY")
else
return '' unless v
return '' unless v and Number.isFinite(v)
if v % 7 is 0
v = v / 7
if v > 0 then "X+#{v}w" else "X#{v}w"
else
if v > 0 then "X+#{v}" else "X#{v}"
actions:
range_changed: ([range_start, range_end]) ->
[range_start, range_end] = [Math.round(range_start), Math.round(range_end)]
@setProperties
range_start_default: [range_start, range_end]
range_start: range_start
range_end: range_end
batch_size_simulation_change: (value) ->
@setProperties
range_start: null
range_end: null
batch_size_simulation_value: Math.round(value)
hide_absolute_date_choice: ->
@set 'chosen_absolute_date', null # important! determinse wether the labels use X or dates
@set 'choose_reference_date', false
#didReceiveAttrs: ->
init: ->
@_super arguments...
@set 'batch_size_simulation_value', @get('project.batch_size_lower_value') if not @get('batch_size_simulation_value') and @get('project.batch_size_lower_value')
didReceiveAttrs: ->
console.log 'Reload workflow-group-offset-diagram call from after render'
Ember.run.scheduleOnce 'afterRender', @, @_setup_graph
#_graph_observer: Ember.observer 'workflow_groups.@each.{offset_value,name,duration_value}', 'range_start', 'range_end', 'batch_size_simulation_value', 'batch_size_simulation_range', 'reference_date_day_of_week', 'chosen_absolute_date', ->
_graph_observer: Ember.observer 'project.update_revision', '{range_start,range_end}', ->
console.log 'Reload workflow-group-offset-diagram call from observer'
Ember.run.debounce @, @_setup_graph, 150
_setup_graph: ->
console.log 'Reload workflow-group-offset-diagram'
container_selector = '.workflow-group-offset-diagram-container'
component = @
@$(container_selector).html('').append '<svg></svg>'
workflow_groups = @get('workflow_groups').toArray()
return unless workflow_groups.length
active_workflow_group_ids = workflow_groups.mapBy('id').toArray()
# create conditioned workflow_groups
reference_date_day_of_week = @get('reference_date_day_of_week')
if @get('chosen_absolute_date')
reference_date = moment(@get('chosen_absolute_date'))
else
reference_date = moment()
reference_date.day reference_date_day_of_week if reference_date_day_of_week # updates reference_date instance
wrappers = []
x_min = -1 # at least include zero
x_max = 0
batch_size_simulation_value = @get('batch_size_simulation_value')
# recursive adder
add_dependent_wrapper_to_wrappers = (base_wrapper, record) ->
wrapper = GraphWrapper.create
record: record
if record.get('offset_type') is 'connected_finish'
wrapper.set 'finish.value', base_wrapper.get('start.value') - (record.get('offset_value') || 0)
wrapper.set 'start.constant_value', wrapper.get('finish.value') - (record.get('duration_value') || 0)
if batch_size_simulation_value and threshold = wrapper.get('record.batch_element_threshold_value')
# see how many times the threshold is exceeded. Within the threshold means no extra time
threshold_exceedance = Math.floor(batch_size_simulation_value / (0.1 + parseInt(threshold))) # + 0.1 (delta) to exclude exact match of batch_size and threshold
if threshold_exceedance
wrapper.set 'has_variable_value_part', true
period_unit = wrapper.get('record.batch_element_threshold_unit')
base_date = reference_date.clone().add(wrapper.get('start.constant_value'), 'day')
if period_unit is 'businessday'
wrapper.set 'start.date', base_date.clone().businessSubtract(threshold_exceedance)
else
wrapper.set 'start.date', base_date.clone().subtract(threshold_exceedance, period_unit)
wrapper.set 'start.value', wrapper.get('start.constant_value') - base_date.diff(wrapper.get('start.date'), 'day')
else
wrapper.set 'start.value', wrapper.get('start.constant_value')
else
wrapper.set 'start.value', wrapper.get('start.constant_value')
else #if record.get('offset_type') is 'connected_start'
wrapper.set 'start.value', base_wrapper.get('finish.value') + (record.get('offset_value') || 0)
wrapper.set 'finish.constant_value', wrapper.get('start.value') + (record.get('duration_value') || 0)
if batch_size_simulation_value and threshold = wrapper.get('record.batch_element_threshold_value')
if threshold_exceedance = Math.floor(batch_size_simulation_value / (0.1 + parseInt(threshold))) # + 0.1 (delta) to exclude exact match of batch_size and threshold
wrapper.set 'has_variable_value_part', true
period_unit = wrapper.get('record.batch_element_threshold_unit')
base_date = reference_date.clone().add(wrapper.get('finish.constant_value'))
if period_unit is 'businessday'
wrapper.set 'finish.date', base_date.clone().businessAdd(threshold_exceedance)
else
wrapper.set 'finish.date', base_date.clone().add(threshold_exceedance, period_unit)
wrapper.set 'finish.value', wrapper.get('finish.constant_value') + wrapper.get('finish.date').diff(base_date, 'day')
else
wrapper.set 'finish.value', wrapper.get('finish.constant_value')
else
wrapper.set 'finish.value', wrapper.get('finish.constant_value')
wrapper.set 'start.date', reference_date.clone().add(wrapper.get('start.value'), 'day') unless wrapper.get('start.date')
wrapper.set 'finish.date', reference_date.clone().add(wrapper.get('finish.value'), 'day') unless wrapper.get('finish.date')
x_min = Math.min(x_min, wrapper.get('start.value'))
x_max = Math.max(x_max, wrapper.get('finish.value'))
wrappers.push wrapper
record.get('offset_dependents').forEach (offset_dependent_record) ->
add_dependent_wrapper_to_wrappers wrapper, offset_dependent_record if offset_dependent_record.get('id') in active_workflow_group_ids
workflow_groups.rejectBy('offset_base.content').forEach (record) ->
wrapper = GraphWrapper.create
record: record
add_dependent_wrapper_to_wrappers wrapper, record
wrappers = wrappers.sortBy('record.position')
x_max += 1 # design space at the right
if @get('range_bounds.min') isnt x_min or @get('range_bounds.max') isnt x_max
@set 'range_bounds', min: x_min, max: x_max
@set 'range_start_default', [x_min, x_max] # bounds have changed, reset starts
else if not @get('range_start_default') # uninitialized
@set 'range_start_default', [x_min, x_max]
graph_min = @get('range_start') || x_min
graph_max = @get('range_end') || x_max
graph_range_width = @get('svg_width') - @get('left_space') - 22
x = d3.scale.linear().domain([graph_min, graph_max]).range([0, graph_range_width])
#yscale = d3.scale.linear().domain([0, @get('number_of_groups')]).range([0, @get('graph_height')])
y = d3.scale.ordinal().rangeRoundBands([@get('graph_height'), 0], 0.1).domain(wrappers.mapBy('record.name').reverse())
x_ticks = []
wrappers.forEach (wrapper) ->
[start, finish] = wrapper.getPropertyValues('start.value', 'finish.value')
x_ticks.push start if x_ticks.indexOf(start) < 0
x_ticks.push finish if x_ticks.indexOf(finish) < 0
#workflow_groups.forEach (workflow_action_definition) ->
# offset_value = workflow_action_definition.get('offset_value')
# x_ticks.push offset_value unless offset_value is 0 or x_ticks.indexOf(offset_value) > -1
# if duration_value = workflow_action_definition.get('duration_value')
# end_time = offset_value + duration_value
# x_ticks.push end_time unless end_time is 0 or x_ticks.indexOf(end_time) > -1
x_ticks = x_ticks.filter( (v) -> v >= graph_min and v <= graph_max).sort(d3.ascending)
# now remove the ticks not having enough space
displayed_x_ticks = []
if x_ticks.length
displayed_x_ticks.push x_ticks[0]
latest_pixel_x_for_space = x(x_ticks[0])
x_ticks.forEach (v) ->
current_pixel_x = x(v)
if current_pixel_x - latest_pixel_x_for_space > 14
latest_pixel_x_for_space = current_pixel_x
displayed_x_ticks.push v
svg_height = @get('x_axis_space') + @get('graph_height')
canvas = d3.select("#{container_selector} svg")
.attr 'width', '100%'
.attr 'height', svg_height
.attr 'viewBox', "0 0 #{@get('svg_width')} #{svg_height}"
lines_data = x_ticks
lines_data = lines_data.slice(1) if x(lines_data[0]) is graph_min # first element is y-axis, so no tick
lines = canvas.append('g')
.attr('class', 'offset-lines')
.attr('transform', "translate(#{@get('left_space')},0)")
.selectAll('line')
.data(lines_data)
.enter()
.append('line')
.attr(
x1: (d) -> x(d)
y1: 0
x2: (d) -> x(d)
y2: @get('graph_height') + 5
class: 'offset-line'
)
if graph_max >= 0 and graph_min <= 0 # base line is zero
base_line = canvas.append('line').attr
x1: x(0)
y1: 0
x2: x(0)
y2: 15 + @get('graph_height')
class: 'base-line'
transform: "translate(#{@get('left_space')},0)"
bars_grid = canvas.append('g')
.attr('transform', "translate(#{@get('left_space')},0)")
.attr('class', 'bars')
bars = bars_grid.selectAll('.bar')
.data(wrappers.filter((d) -> (d.get('finish.value') > graph_min) and (d.get('start.value') < graph_max)))
.enter()
bars.append('rect')
.attr('class', (d) -> "bar lead-time-#{d.get('duration') || 'none'}")
.attr('x', (d) -> x(Math.max(d.get('start.value'), graph_min)))
.attr('y', (d) -> y(d.get('record.name')))
.attr('height', y.rangeBand())
.attr 'width', (d) ->
if duration = d.get('duration')
display_range = duration
if d.get('start.value') < graph_min # remove difference from width
display_range = display_range + d.get('start.value') - graph_min
if d.get('finish.value') > graph_max # remove difference from width right threshold
display_range = display_range + graph_max - d.get('finish.value')
graph_range_width * display_range / (graph_max - graph_min)
else
# no duration to display, clickable width, gets different styling
5
.on 'click', (wrapper) =>
return unless wrapper?.get('record')
@modal 'project/edit-workflow-group',
model: wrapper.get('record')
wrappers_having_extra_duration_due_to_batch_simulation = wrappers.filter (d) ->
return false unless d.get('has_variable_value_part')
if d.get('record.offset_type') is 'connected_finish'
d.get('start.constant_value') > graph_min and d.get('start.value') < graph_max
else
d.get('finish.value') > graph_min and d.get('finish.constant_value') < graph_max
if wrappers_having_extra_duration_due_to_batch_simulation.length
canvas.append('g')
.attr('transform', "translate(#{@get('left_space')},0)")
.attr('class', 'bars-parts-indicating-extra-duration-due-to-batch-size')
.selectAll('.bar')
.data(wrappers_having_extra_duration_due_to_batch_simulation)
.enter()
.append('rect')
.attr('class', "bar extra-duration")
.attr('x', (d) ->
if d.get('record.offset_type') is 'connected_finish'
x(Math.max(d.get('start.value'), graph_min))
else
x(Math.max(d.get('finish.constant_value'), graph_min))
)
.attr('y', (d) -> y(d.get('record.name')))
.attr('height', y.rangeBand())
.attr 'width', (d) ->
if d.get('record.offset_type') is 'connected_finish'
display_range = Math.abs(d.get('start.constant_value') - d.get('start.value'))
if d.get('start.value') < graph_min # remove difference from width
display_range = display_range + d.get('start.value') - graph_min
if d.get('start.constant_value') > graph_max # remove difference from width right threshold
display_range = display_range + graph_max - d.get('start.constant_value')
graph_range_width * display_range / (graph_max - graph_min)
else
display_range = Math.abs(d.get('finish.value') - d.get('finish.constant_value'))
if d.get('finish.constant_value') < graph_min # remove difference from width
display_range = display_range + d.get('finish.constant_value') - graph_min
if d.get('finish.value') > graph_max # remove difference from width right threshold
display_range = display_range + graph_max - d.get('finish.value')
graph_range_width * display_range / (graph_max - graph_min)
# bars crossing the left y-axis due to slider restrictions. Show text
groups_crossing_left_axis = wrappers.filter (d) ->
d.get('start.value') < graph_min and (d.get('finish.value') >= graph_min) # allow connecting edge, therefore >= in stead of >
if groups_crossing_left_axis.length
canvas.append('g')
.attr('transform', "translate(#{@get('left_space')},0)")
.attr('class', 'groups-crossing-left-axis')
.selectAll('text')
.data(groups_crossing_left_axis)
.enter()
.append('text')
.attr('y', (d) -> 14 + y(d.get('record.name'))) # 14 is visually chosen, a bit less than bar_height
.html((d) -> "&larr; #{component.days_offset_format(d.get('start.value'))}")
# bars crossing the right y-axis due to slider restrictions. Show text
groups_crossing_right_axis = wrappers.filter (d) ->
d.get('start.value') < graph_max and (d.get('finish.value') > graph_max)
if groups_crossing_right_axis.length
canvas.append('g')
.attr('transform', "translate(#{@get('left_space')},0)")
.attr('class', 'groups-crossing-right-axis')
.selectAll('text')
.data(groups_crossing_right_axis)
.enter()
.append('text')
.attr('y', (d) -> 14 + y(d.get('record.name'))) # 14 is visually chosen, a bit less than bar_height
.attr('x', graph_range_width)
.html((d) -> "#{component.days_offset_format(d.get('finish.value'))} &rarr;")
yAxis = d3.svg.axis()
yAxis
.orient('left')
.scale(y)
.tickSize(1)
y_xis = canvas.append('g')
.attr('transform', "translate(#{@get('left_space')},0)")
.attr('class','y-axis')
.call(yAxis)
xAxis = d3.svg.axis()
.orient('bottom')
.scale(x)
.tickSize(1)
.tickValues(displayed_x_ticks)
.tickFormat component.days_offset_format.bind(component)
if displayed_x_ticks.length
x_xis = canvas.append('g')
.attr('transform', "translate(#{@get('left_space')},#{@get('graph_height')})")
.attr('class','x-axis')
.call(xAxis)
.selectAll('text')
.style('text-anchor', 'end')
.attr('dx', '-.8em')
.attr('dy', '.15em')
.attr('transform', "rotate(-40)")
canvas.selectAll('.y-axis .tick').on 'click', (d) =>
return unless workflow_group = workflow_groups.findBy 'name', d
@modal 'project/edit-workflow-group',
model: workflow_group
@set 'd3graph', canvas