Add dashboard performance trends
This commit is contained in:
+138
-21
@@ -2,39 +2,156 @@ extends Control
|
||||
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
|
||||
var values: PackedFloat32Array = []
|
||||
var points: Array = []
|
||||
var mode := "rating"
|
||||
var dark := true
|
||||
var hover_index := -1
|
||||
|
||||
|
||||
func set_values(next_values: Array, dark_mode: bool) -> void:
|
||||
values = PackedFloat32Array(next_values)
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
|
||||
|
||||
func set_series(next_points: Array, next_mode: String, dark_mode: bool) -> void:
|
||||
points = next_points.duplicate(true)
|
||||
mode = next_mode
|
||||
dark = dark_mode
|
||||
hover_index = -1
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_RESIZED:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseMotion:
|
||||
var next_hover := _index_near_x(event.position.x)
|
||||
if next_hover != hover_index:
|
||||
hover_index = next_hover
|
||||
queue_redraw()
|
||||
elif event is InputEventMouseButton and not event.pressed:
|
||||
hover_index = _index_near_x(event.position.x)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var left := 42.0
|
||||
var left := 52.0
|
||||
var top := 12.0
|
||||
var right := size.x - 12.0
|
||||
var bottom := size.y - 28.0
|
||||
var right := maxf(left + 1.0, size.x - 12.0)
|
||||
var bottom := maxf(top + 1.0, size.y - 34.0)
|
||||
var grid := Color("24314b") if dark else Color("dbe3ef")
|
||||
var muted := Brand.muted_color(dark)
|
||||
for percentage in [0, 25, 50, 75, 100]:
|
||||
var y: float = bottom - (bottom - top) * float(percentage) / 100.0
|
||||
var bounds := axis_bounds(points, mode)
|
||||
var minimum := float(bounds.x)
|
||||
var maximum := float(bounds.y)
|
||||
for tick_index in range(5):
|
||||
var ratio := float(tick_index) / 4.0
|
||||
var y := bottom - (bottom - top) * ratio
|
||||
var tick_value := lerpf(minimum, maximum, ratio)
|
||||
draw_line(Vector2(left, y), Vector2(right, y), grid, 1.0)
|
||||
draw_string(Brand.font, Vector2(4, y + 4), str(percentage), HORIZONTAL_ALIGNMENT_LEFT, 34, 11, muted)
|
||||
var target_y: float = bottom - (bottom - top) * 0.70
|
||||
draw_dashed_line(Vector2(left, target_y), Vector2(right, target_y), Brand.ROYAL_BLUE, 5.0, 2.0)
|
||||
if values.is_empty():
|
||||
draw_string(Brand.font, Vector2(2, y + 4), str(int(round(tick_value))), HORIZONTAL_ALIGNMENT_RIGHT, 43, 11, muted)
|
||||
if mode == "accuracy":
|
||||
var target_y := _value_y(70.0, minimum, maximum, top, bottom)
|
||||
draw_dashed_line(Vector2(left, target_y), Vector2(right, target_y), Brand.ROYAL_BLUE, 5.0, 2.0)
|
||||
if points.is_empty():
|
||||
return
|
||||
var points := PackedVector2Array()
|
||||
var count: int = values.size()
|
||||
for index in range(count):
|
||||
var x: float = left if count == 1 else lerpf(left, right, float(index) / float(count - 1))
|
||||
var y: float = bottom - (bottom - top) * clampf(values[index], 0.0, 100.0) / 100.0
|
||||
points.append(Vector2(x, y))
|
||||
if points.size() > 1:
|
||||
draw_polyline(points, Brand.ROYAL_BLUE, 2.5, true)
|
||||
for point in points:
|
||||
|
||||
var previous: Variant = null
|
||||
for index in range(points.size()):
|
||||
var value: Variant = point_value(points[index], mode)
|
||||
if value == null:
|
||||
previous = null
|
||||
continue
|
||||
var point := Vector2(_point_x(index, left, right), _value_y(float(value), minimum, maximum, top, bottom))
|
||||
if previous != null:
|
||||
draw_line(previous, point, Brand.ROYAL_BLUE, 2.5, true)
|
||||
draw_circle(point, 3.5, Brand.PURPLE)
|
||||
draw_circle(point, 2.0, Color.WHITE)
|
||||
previous = point
|
||||
|
||||
for label_index in [0, int(floor(float(points.size() - 1) / 2.0)), points.size() - 1]:
|
||||
var x := _point_x(label_index, left, right)
|
||||
var label := _short_date(str((points[label_index] as Dictionary).get("dateUtc", "")))
|
||||
draw_string(Brand.font, Vector2(x - 24, bottom + 19), label, HORIZONTAL_ALIGNMENT_CENTER, 48, 10, muted)
|
||||
|
||||
if hover_index >= 0 and hover_index < points.size():
|
||||
var hover_value: Variant = point_value(points[hover_index], mode)
|
||||
if hover_value != null:
|
||||
var hover_x := _point_x(hover_index, left, right)
|
||||
var hover_y := _value_y(float(hover_value), minimum, maximum, top, bottom)
|
||||
draw_line(Vector2(hover_x, top), Vector2(hover_x, bottom), muted, 1.0)
|
||||
draw_circle(Vector2(hover_x, hover_y), 5.0, Brand.ROYAL_BLUE)
|
||||
var suffix := "%" if mode == "accuracy" else ""
|
||||
var tooltip := "%s %d%s" % [
|
||||
_short_date(str((points[hover_index] as Dictionary).get("dateUtc", ""))),
|
||||
int(hover_value),
|
||||
suffix
|
||||
]
|
||||
var tooltip_size := Brand.font.get_string_size(tooltip, HORIZONTAL_ALIGNMENT_LEFT, -1, 11) + Vector2(16, 10)
|
||||
var tooltip_x := clampf(hover_x - tooltip_size.x / 2.0, left, right - tooltip_size.x)
|
||||
var tooltip_y := maxf(top, hover_y - tooltip_size.y - 10.0)
|
||||
var tooltip_rect := Rect2(Vector2(tooltip_x, tooltip_y), tooltip_size)
|
||||
draw_style_box(_tooltip_box(), tooltip_rect)
|
||||
draw_string(Brand.font, Vector2(tooltip_x + 8, tooltip_y + tooltip_size.y - 7), tooltip, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Brand.text_color(dark))
|
||||
|
||||
|
||||
static func point_value(point: Dictionary, series_mode: String) -> Variant:
|
||||
if series_mode == "accuracy":
|
||||
return float(point.get("accuracy", 0)) if int(point.get("total", 0)) > 0 else null
|
||||
var value: Variant = point.get("questionBankRating", null)
|
||||
return null if value == null else float(value)
|
||||
|
||||
|
||||
static func axis_bounds(series_points: Array, series_mode: String) -> Vector2:
|
||||
if series_mode == "accuracy":
|
||||
return Vector2(0, 100)
|
||||
var values: Array[float] = []
|
||||
for point in series_points:
|
||||
var value: Variant = point_value(point, series_mode)
|
||||
if value != null:
|
||||
values.append(float(value))
|
||||
if values.is_empty():
|
||||
return Vector2(900, 1100)
|
||||
var minimum: float = values.min()
|
||||
var maximum: float = values.max()
|
||||
if maximum - minimum < 100.0:
|
||||
var center: float = (minimum + maximum) / 2.0
|
||||
minimum = center - 50.0
|
||||
maximum = center + 50.0
|
||||
var padding := maxf(25.0, (maximum - minimum) * 0.15)
|
||||
minimum = floor((minimum - padding) / 25.0) * 25.0
|
||||
maximum = ceil((maximum + padding) / 25.0) * 25.0
|
||||
return Vector2(minimum, maximum)
|
||||
|
||||
|
||||
func _index_near_x(mouse_x: float) -> int:
|
||||
if points.is_empty() or size.x <= 64.0:
|
||||
return -1
|
||||
var ratio := clampf((mouse_x - 52.0) / maxf(1.0, size.x - 64.0), 0.0, 1.0)
|
||||
return int(round(ratio * float(points.size() - 1)))
|
||||
|
||||
|
||||
func _point_x(index: int, left: float, right: float) -> float:
|
||||
return left if points.size() <= 1 else lerpf(left, right, float(index) / float(points.size() - 1))
|
||||
|
||||
|
||||
func _value_y(value: float, minimum: float, maximum: float, top: float, bottom: float) -> float:
|
||||
return bottom - (bottom - top) * inverse_lerp(minimum, maximum, clampf(value, minimum, maximum))
|
||||
|
||||
|
||||
func _short_date(iso_value: String) -> String:
|
||||
var date := iso_value.left(10)
|
||||
if date.length() < 10:
|
||||
return ""
|
||||
return "%s/%s" % [date.substr(5, 2), date.substr(8, 2)]
|
||||
|
||||
|
||||
func _tooltip_box() -> StyleBoxFlat:
|
||||
var box := StyleBoxFlat.new()
|
||||
box.bg_color = Color("111d31") if dark else Color.WHITE
|
||||
box.border_color = Color("34435e") if dark else Brand.LIGHT_BORDER
|
||||
box.set_border_width_all(1)
|
||||
box.set_corner_radius_all(5)
|
||||
return box
|
||||
|
||||
@@ -12,6 +12,8 @@ var metric_grid: GridContainer
|
||||
var analytics_grid: GridContainer
|
||||
var lower_grid: GridContainer
|
||||
var trend_chart: Control
|
||||
var rating_toggle: Button
|
||||
var accuracy_toggle: Button
|
||||
var activity_chart: Control
|
||||
var system_list: VBoxContainer
|
||||
var focus_content: VBoxContainer
|
||||
@@ -22,6 +24,7 @@ var metric_values: Dictionary = {}
|
||||
var metric_details: Dictionary = {}
|
||||
var pending_review_launch := false
|
||||
var review_queue_service: Variant
|
||||
var trend_mode := "rating"
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -81,7 +84,18 @@ func _build() -> void:
|
||||
var performance := VBoxContainer.new()
|
||||
performance.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
performance.add_theme_constant_override("separation", 10)
|
||||
performance.add_child(_section_title("Performance trend"))
|
||||
var trend_header := HBoxContainer.new()
|
||||
trend_header.add_child(_section_title("Performance trend"))
|
||||
var trend_space := Control.new()
|
||||
trend_space.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
trend_header.add_child(trend_space)
|
||||
var trend_group := ButtonGroup.new()
|
||||
trend_group.allow_unpress = false
|
||||
rating_toggle = _trend_toggle("Rating", "rating", trend_group)
|
||||
accuracy_toggle = _trend_toggle("Correct %", "accuracy", trend_group)
|
||||
trend_header.add_child(rating_toggle)
|
||||
trend_header.add_child(accuracy_toggle)
|
||||
performance.add_child(trend_header)
|
||||
trend_chart = TrendChart.new()
|
||||
trend_chart.custom_minimum_size = Vector2(520, 190)
|
||||
trend_chart.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
@@ -153,11 +167,7 @@ func _render() -> void:
|
||||
_set_metric("ccs", str(int(data.get("ccsRating", 1000))), "%d rated cases" % int(data.get("ratedCcsCaseCount", 0)))
|
||||
_set_metric("accuracy", "%d%%" % int(data.get("firstAttemptAccuracy", 0)), "Last %d questions" % int(data.get("accuracySampleSize", 0)))
|
||||
_set_metric("time", _format_duration(int(data.get("studyTimeSecondsThisWeek", 0))), "This week")
|
||||
var trend_values: Array = []
|
||||
for point in data.get("performanceTrend", []):
|
||||
if int(point.get("total", 0)) > 0:
|
||||
trend_values.append(float(point.get("accuracy", 0)))
|
||||
trend_chart.set_values(trend_values, UIThemeService.is_dark_mode())
|
||||
trend_chart.set_series(data.get("performanceTrend", []), trend_mode, UIThemeService.is_dark_mode())
|
||||
_render_systems(data.get("systems", []))
|
||||
_render_focus(data.get("systems", []))
|
||||
_render_review(data)
|
||||
@@ -170,38 +180,52 @@ func _render() -> void:
|
||||
func _render_systems(systems_value: Array) -> void:
|
||||
_clear(system_list)
|
||||
var systems := systems_value.duplicate(true)
|
||||
systems.sort_custom(func(a: Dictionary, b: Dictionary): return int(a.get("total", 0)) > int(b.get("total", 0)))
|
||||
if systems.is_empty():
|
||||
system_list.add_child(_label("Answer questions to build your system breakdown.", 14, Brand.muted_color(UIThemeService.is_dark_mode())))
|
||||
return
|
||||
var displayed: Array = systems.slice(0, mini(5, systems.size()))
|
||||
var best_accuracy := -1
|
||||
var worst_accuracy := 101
|
||||
for item in displayed:
|
||||
best_accuracy = maxi(best_accuracy, int(item.get("accuracy", 0)))
|
||||
worst_accuracy = mini(worst_accuracy, int(item.get("accuracy", 0)))
|
||||
for item in displayed:
|
||||
var attempted: Array = systems.filter(func(item: Dictionary): return int(item.get("total", 0)) > 0)
|
||||
var strongest_system := ""
|
||||
var weakest_system := ""
|
||||
if not attempted.is_empty():
|
||||
var strongest: Dictionary = attempted[0]
|
||||
var weakest: Dictionary = attempted[0]
|
||||
for item in attempted:
|
||||
if int(item.get("accuracy", 0)) > int(strongest.get("accuracy", 0)) or (
|
||||
int(item.get("accuracy", 0)) == int(strongest.get("accuracy", 0))
|
||||
and int(item.get("total", 0)) > int(strongest.get("total", 0))):
|
||||
strongest = item
|
||||
if int(item.get("accuracy", 0)) < int(weakest.get("accuracy", 0)) or (
|
||||
int(item.get("accuracy", 0)) == int(weakest.get("accuracy", 0))
|
||||
and int(item.get("total", 0)) > int(weakest.get("total", 0))):
|
||||
weakest = item
|
||||
strongest_system = str(strongest.get("system", ""))
|
||||
weakest_system = str(weakest.get("system", ""))
|
||||
for item in systems:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 10)
|
||||
var system_name_label := _label(str(item.get("system", "General")), 13, Brand.text_color(UIThemeService.is_dark_mode()))
|
||||
system_name_label.custom_minimum_size.x = 190
|
||||
system_name_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
system_name_label.custom_minimum_size.x = 170
|
||||
system_name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
system_name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
row.add_child(system_name_label)
|
||||
var bar := ProgressBar.new()
|
||||
bar.show_percentage = false
|
||||
bar.value = int(item.get("accuracy", 0))
|
||||
bar.custom_minimum_size = Vector2(150, 7)
|
||||
bar.custom_minimum_size = Vector2(80, 7)
|
||||
bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
bar.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
_style_progress(bar, Brand.PURPLE if int(item.get("accuracy", 0)) == worst_accuracy else Brand.ROYAL_BLUE)
|
||||
_style_progress(bar, Brand.PURPLE if str(item.get("system", "")) == weakest_system else Brand.ROYAL_BLUE)
|
||||
row.add_child(bar)
|
||||
var percentage := _label("%d%%" % int(item.get("accuracy", 0)), 14, Brand.ROYAL_BLUE, true)
|
||||
var total := int(item.get("total", 0))
|
||||
var percentage_text := "%d%%" % int(item.get("accuracy", 0)) if total > 0 else "—"
|
||||
var percentage := _label(percentage_text, 14, Brand.ROYAL_BLUE, true)
|
||||
percentage.custom_minimum_size.x = 48
|
||||
row.add_child(percentage)
|
||||
var count := _label("%d / %d" % [int(item.get("correct", 0)), int(item.get("total", 0))], 12, Brand.muted_color(UIThemeService.is_dark_mode()))
|
||||
var count := _label("%d / %d" % [int(item.get("correct", 0)), total], 12, Brand.muted_color(UIThemeService.is_dark_mode()))
|
||||
count.custom_minimum_size.x = 58
|
||||
row.add_child(count)
|
||||
var marker := "Strongest" if int(item.get("accuracy", 0)) == best_accuracy else ("Needs work" if int(item.get("accuracy", 0)) == worst_accuracy else "")
|
||||
var system_name := str(item.get("system", ""))
|
||||
var marker := "Strongest" if system_name == strongest_system else ("Needs work" if system_name == weakest_system else "")
|
||||
var marker_label := _label(marker, 11, Brand.SUCCESS if marker == "Strongest" else Brand.MAGENTA)
|
||||
marker_label.custom_minimum_size.x = 72
|
||||
row.add_child(marker_label)
|
||||
@@ -444,6 +468,33 @@ func _button(text: String, icon_name: String = "", primary: bool = true) -> Butt
|
||||
return button
|
||||
|
||||
|
||||
func _trend_toggle(text: String, mode: String, group: ButtonGroup) -> Button:
|
||||
var button := Button.new()
|
||||
button.text = text
|
||||
button.toggle_mode = true
|
||||
button.button_group = group
|
||||
button.button_pressed = trend_mode == mode
|
||||
button.custom_minimum_size = Vector2(86, 32)
|
||||
button.add_theme_font_override("font", Brand.font)
|
||||
button.add_theme_font_size_override("font_size", 12)
|
||||
var normal := Brand.secondary_button_box(UIThemeService.is_dark_mode())
|
||||
var selected := StyleBoxFlat.new()
|
||||
selected.bg_color = Brand.ROYAL_BLUE
|
||||
selected.set_corner_radius_all(6)
|
||||
button.add_theme_stylebox_override("normal", normal)
|
||||
button.add_theme_stylebox_override("hover", normal)
|
||||
button.add_theme_stylebox_override("pressed", selected)
|
||||
button.add_theme_color_override("font_pressed_color", Color.WHITE)
|
||||
button.pressed.connect(_set_trend_mode.bind(mode))
|
||||
return button
|
||||
|
||||
|
||||
func _set_trend_mode(mode: String) -> void:
|
||||
trend_mode = mode
|
||||
if trend_chart != null and not DashboardService.data.is_empty():
|
||||
trend_chart.set_series(DashboardService.data.get("performanceTrend", []), trend_mode, UIThemeService.is_dark_mode())
|
||||
|
||||
|
||||
func _label(text: String, font_size: int, color: Color, bold: bool = false) -> Label:
|
||||
var label := Label.new()
|
||||
label.text = text
|
||||
@@ -489,6 +540,8 @@ func _update_responsive() -> void:
|
||||
metric_grid.columns = 2 if compact else 4
|
||||
analytics_grid.columns = 1 if compact else 2
|
||||
lower_grid.columns = 1 if compact else 2
|
||||
if trend_chart != null:
|
||||
trend_chart.custom_minimum_size.x = 300.0 if compact else 520.0
|
||||
|
||||
|
||||
func _clear(container: Container) -> void:
|
||||
|
||||
Reference in New Issue
Block a user