diff --git a/docs/design/gui.md b/docs/design/gui.md index 96fb1e503e..ce389340ad 100644 --- a/docs/design/gui.md +++ b/docs/design/gui.md @@ -278,6 +278,16 @@ Alright, I think this is the sequence of things to do: 2) make it possible to completely reload UI and everything from scratch, from a plugin. rationale: it'd be nice to switch maps from inside the editor anyway. not necessary, but useful. 3) make road edits propogate correctly, and somehow have a strategy for ensuring nothing is forgotten. impl today is VERY incomplete. +Thinking about this again now that we need two copies of everything to be alive at a time and switch between them... + +- very tied together: map, control map, draw map, sim +- current selection is UI state that has to refresh when changing maps +- which plugins have state tied to the map? + - have a method on UI to switch map+edits? no, dont want to reload all this stuff every time... + - bundle that state together, including the plugins! + - make the existing 'load new edits' thing use this new mechanism + - then go back to managing a second sim... + ## Rendering a map differently For "Project Halloween", I want to draw the map model in a very different @@ -298,3 +308,48 @@ So, try adding the quadtree for roads and buildings (diff quadtrees or one unified? hmm) and see what looks common. Remember we could use quadtrees in map model construction for building/sidewalk pruning, but there's the awkwardness of quadtrees _kind of_ being a UI concept. + +## Side-by-side + +What should this feature do? Is it easier to watch two maps side-by-side moving +in lockstep, with the same camera? Or would a ghostly trace overlay on one map +be easier to understand? The use cases: + +- Glancing at a vague overview of how the two runs are doing. Watching graphs + side-by-side might even be more useful here. But for a zoomed out view, + side-by-side with reasonably clear pockets of color (weather model style, + almost) seems nice. +- Detailed inspection of a fixed area. Side-by-side view with full detail seems + nice. +- Detailed inspection of a specific agent, following it. Side-by-side would + have to trace it in both canvases. +- Looking for differences... what are these? For a single agent, wanting to + know are they farther along their journey at some point in time? That could + be visualized nicely with a red or green thick route in front or behind them. + Maybe they're ahead of the baseline by some amount, or behind it. This could + use relative score or relative distance to goal or something. Would it trace + ahead by pure distance or by anticipated distance in a given time? + +The side-by-side canvas seems incredibly difficult -- have to revamp everything +to dispatch mouse events, maybe synchronize cameras, other plugins +arbitrarily... No way. + +Let's start with two concrete things: + +- Start running an A/B test sets up a second optional simulation in the UI. + Some keys can toggle between showing one of the two, for now. Stepping will + step BOTH of them. Have to adjust the OSD and other sim control things. + Possibly even sim control should own the second sim? + - Q: how to prevent scenario instatiation or adding new agents while an + A/B test is in progress? need plugin groups / modes! + - road editing during an A/B test is _definitely_ not k + - argh!! wait, we need a different map, since we need different edits! + - that means also a different control map + - should the Sim own map and control_map to make it clear they're tied? I think so! + - practically all of the UI touches the map... + - wait wait draw map also needs to be duplicated then. + - we're back to the problem of loading map edits +- Make a visual trace abstraction to show something in front of or behind an + agent. It follows bends in the road, crosses intersections, etc. Could be + used for lookahead debugging right now, and this relative ghost comparison + thing next. diff --git a/editor/src/plugins/turn_cycler.rs b/editor/src/plugins/turn_cycler.rs index ee81733472..971f559ddb 100644 --- a/editor/src/plugins/turn_cycler.rs +++ b/editor/src/plugins/turn_cycler.rs @@ -8,7 +8,7 @@ use objects::{Ctx, ID}; use piston::input::Key; use plugins::Colorizer; use render::DrawMap; -use sim::Sim; +use sim::Tick; #[derive(Clone, Debug)] pub enum TurnCyclerState { @@ -68,7 +68,7 @@ impl TurnCyclerState { map: &Map, draw_map: &DrawMap, control_map: &ControlMap, - sim: &Sim, + time: Tick, cs: &ColorScheme, g: &mut GfxCtx, ) { @@ -92,7 +92,7 @@ impl TurnCyclerState { } TurnCyclerState::Intersection(id) => { if let Some(signal) = control_map.traffic_signals.get(&id) { - let (cycle, _) = signal.current_cycle_and_remaining_time(sim.time.as_time()); + let (cycle, _) = signal.current_cycle_and_remaining_time(time.as_time()); for t in &cycle.turns { draw_map.get_t(*t).draw_full(g, cs.get(Colors::Turn)); } diff --git a/editor/src/render/map.rs b/editor/src/render/map.rs index 12597e9431..c9aa95badc 100644 --- a/editor/src/render/map.rs +++ b/editor/src/render/map.rs @@ -35,16 +35,13 @@ pub struct DrawMap { pub bus_stops: HashMap, pub areas: Vec, + pub center_pt: Pt2D, + quadtree: QuadTree, } impl DrawMap { - // Also returns the center of the map in map-space - pub fn new( - map: &Map, - control_map: &ControlMap, - raw_extra_shapes: Vec, - ) -> (DrawMap, Pt2D) { + pub fn new(map: &Map, control_map: &ControlMap, raw_extra_shapes: Vec) -> DrawMap { let mut lanes: Vec = Vec::new(); for l in map.all_lanes() { lanes.push(DrawLane::new(l, map, control_map)); @@ -120,21 +117,20 @@ impl DrawMap { quadtree.insert_with_box(obj.get_id(), get_bbox(obj.get_bounds())); } - ( - DrawMap { - lanes, - intersections, - turns, - buildings, - parcels, - extra_shapes, - bus_stops, - areas, + DrawMap { + lanes, + intersections, + turns, + buildings, + parcels, + extra_shapes, + bus_stops, + areas, - quadtree, - }, - Pt2D::new(max_screen_pt.x() / 2.0, max_screen_pt.y() / 2.0), - ) + center_pt: Pt2D::new(max_screen_pt.x() / 2.0, max_screen_pt.y() / 2.0), + + quadtree, + } } fn compute_turn_to_lane_offset(result: &mut HashMap, l: &Lane, map: &Map) { diff --git a/editor/src/ui.rs b/editor/src/ui.rs index 5419836d10..381cac47a5 100644 --- a/editor/src/ui.rs +++ b/editor/src/ui.rs @@ -10,8 +10,7 @@ use ezgui::{ }; use flame; use kml; -use map_model; -use map_model::IntersectionID; +use map_model::{IntersectionID, Map}; use objects::{Ctx, DEBUG_LAYERS, ID, ROOT_MENU}; use piston::input::Key; use plugins::a_b_tests::ABTestManager; @@ -82,55 +81,16 @@ impl UIWrapper { // Do this first, so anything logged by sim::load isn't lost. let logs = DisplayLogs::new(); - flame::start("setup"); - let (map, control_map, sim) = sim::load(flags.clone(), Some(sim::Tick::from_seconds(30))); - let extra_shapes = if let Some(path) = kml.clone() { - kml::load(&path, &map.get_gps_bounds()).expect("Couldn't load extra KML shapes") - } else { - Vec::new() - }; - - flame::start("draw_map"); - let (draw_map, center_pt) = DrawMap::new(&map, &control_map, extra_shapes); - flame::end("draw_map"); - - flame::end("setup"); - flame::dump_stdout(); - - let steepness_viz = SteepnessVisualizer::new(&map); - let road_editor = RoadEditor::new(map.get_road_edits().clone()); - let mut ui = UI { - // TODO organize this by section - map, - draw_map, - control_map, - sim, - - steepness_viz, - road_editor, - sim_ctrl: SimController::new(), + primary: PerMapUI::new(flags, kml.clone()), + secondary: None, layers: ToggleableLayers::new(), - - current_selection: None, - - hider: Hider::new(), - debug_objects: DebugObjectsState::new(), search_state: SearchState::Empty, warp: WarpState::Empty, - follow: FollowState::Empty, - show_route: ShowRouteState::Empty, - floodfiller: Floodfiller::new(), osm_classifier: OsmClassifier::new(), - traffic_signal_editor: TrafficSignalEditor::new(), - stop_sign_editor: StopSignEditor::new(), + sim_ctrl: SimController::new(), color_picker: ColorPicker::new(), - geom_validator: Validator::new(), - turn_cycler: TurnCyclerState::new(), - draw_neighborhoods: DrawNeighborhoodState::new(), - scenarios: ScenarioManager::new(), - edits_manager: EditsManager::new(flags), ab_test_manager: ABTestManager::new(), logs, @@ -141,7 +101,7 @@ impl UIWrapper { }; match abstutil::read_json::("editor_state") { - Ok(ref state) if *ui.map.get_name() == state.map_name => { + Ok(ref state) if ui.primary.map.get_name().to_string() == state.map_name => { info!("Loaded previous editor_state"); ui.canvas.cam_x = state.cam_x; ui.canvas.cam_y = state.cam_y; @@ -149,7 +109,7 @@ impl UIWrapper { } _ => { warn!("Couldn't load editor_state or it's for a different map, so just centering initial view"); - ui.canvas.center_on_map_pt(center_pt); + ui.canvas.center_on_map_pt(ui.primary.draw_map.center_pt); } } @@ -173,117 +133,132 @@ impl UIWrapper { changed }; if layer_changed { - ctx.ui.current_selection = ctx.ui.mouseover_something(); + ctx.ui.primary.current_selection = ctx.ui.mouseover_something(); true } else { false } }), Box::new(|ctx| { - ctx.ui.traffic_signal_editor.event( + ctx.ui.primary.traffic_signal_editor.event( ctx.input, - &ctx.ui.map, - &mut ctx.ui.control_map, - ctx.ui.current_selection, + &ctx.ui.primary.map, + &mut ctx.ui.primary.control_map, + ctx.ui.primary.current_selection, ) }), Box::new(|ctx| { - ctx.ui.stop_sign_editor.event( + ctx.ui.primary.stop_sign_editor.event( ctx.input, - &ctx.ui.map, - &mut ctx.ui.control_map, - ctx.ui.current_selection, + &ctx.ui.primary.map, + &mut ctx.ui.primary.control_map, + ctx.ui.primary.current_selection, ) }), Box::new(|ctx| { - ctx.ui.road_editor.event( + ctx.ui.primary.road_editor.event( ctx.input, - ctx.ui.current_selection, - &mut ctx.ui.map, - &mut ctx.ui.draw_map, - &ctx.ui.control_map, - &mut ctx.ui.sim, + ctx.ui.primary.current_selection, + &mut ctx.ui.primary.map, + &mut ctx.ui.primary.draw_map, + &ctx.ui.primary.control_map, + &mut ctx.ui.primary.sim, ) }), Box::new(|ctx| ctx.ui.search_state.event(ctx.input)), Box::new(|ctx| { ctx.ui.warp.event( ctx.input, - &ctx.ui.map, - &ctx.ui.sim, + &ctx.ui.primary.map, + &ctx.ui.primary.sim, &mut ctx.ui.canvas, - &mut ctx.ui.current_selection, + &mut ctx.ui.primary.current_selection, ) }), Box::new(|ctx| { - ctx.ui.follow.event( + ctx.ui.primary.follow.event( ctx.input, - &ctx.ui.map, - &ctx.ui.sim, + &ctx.ui.primary.map, + &ctx.ui.primary.sim, &mut ctx.ui.canvas, - ctx.ui.current_selection, + ctx.ui.primary.current_selection, ) }), Box::new(|ctx| { - ctx.ui - .show_route - .event(ctx.input, &ctx.ui.sim, ctx.ui.current_selection) + ctx.ui.primary.show_route.event( + ctx.input, + &ctx.ui.primary.sim, + ctx.ui.primary.current_selection, + ) }), Box::new(|ctx| { ctx.ui .color_picker .event(ctx.input, &mut ctx.ui.canvas, &mut ctx.ui.cs) }), - Box::new(|ctx| ctx.ui.steepness_viz.event(ctx.input)), + Box::new(|ctx| ctx.ui.primary.steepness_viz.event(ctx.input)), Box::new(|ctx| ctx.ui.osm_classifier.event(ctx.input)), - Box::new(|ctx| ctx.ui.hider.event(ctx.input, &mut ctx.ui.current_selection)), Box::new(|ctx| { - ctx.ui.debug_objects.event( - ctx.ui.current_selection, + ctx.ui + .primary + .hider + .event(ctx.input, &mut ctx.ui.primary.current_selection) + }), + Box::new(|ctx| { + ctx.ui.primary.debug_objects.event( + ctx.ui.primary.current_selection, ctx.input, - &ctx.ui.map, - &mut ctx.ui.sim, - &ctx.ui.control_map, + &ctx.ui.primary.map, + &mut ctx.ui.primary.sim, + &ctx.ui.primary.control_map, ) }), Box::new(|ctx| { - ctx.ui - .floodfiller - .event(&ctx.ui.map, ctx.input, ctx.ui.current_selection) + ctx.ui.primary.floodfiller.event( + &ctx.ui.primary.map, + ctx.input, + ctx.ui.primary.current_selection, + ) }), Box::new(|ctx| { - ctx.ui.geom_validator.event( + ctx.ui.primary.geom_validator.event( ctx.input, &mut ctx.ui.canvas, - &ctx.ui.map, - &ctx.ui.draw_map, + &ctx.ui.primary.map, + &ctx.ui.primary.draw_map, ) }), Box::new(|ctx| { ctx.ui + .primary .turn_cycler - .event(ctx.input, ctx.ui.current_selection) + .event(ctx.input, ctx.ui.primary.current_selection) }), Box::new(|ctx| { - ctx.ui - .draw_neighborhoods - .event(ctx.input, &ctx.ui.canvas, &ctx.ui.map, ctx.osd) - }), - Box::new(|ctx| { - ctx.ui - .scenarios - .event(ctx.input, &ctx.ui.map, &mut ctx.ui.sim) - }), - Box::new(|ctx| { - ctx.ui.edits_manager.event( + ctx.ui.primary.draw_neighborhoods.event( ctx.input, - &ctx.ui.map, - &ctx.ui.control_map, - &ctx.ui.road_editor, + &ctx.ui.canvas, + &ctx.ui.primary.map, + ctx.osd, + ) + }), + Box::new(|ctx| { + ctx.ui.primary.scenarios.event( + ctx.input, + &ctx.ui.primary.map, + &mut ctx.ui.primary.sim, + ) + }), + Box::new(|ctx| { + ctx.ui.primary.edits_manager.event( + ctx.input, + &ctx.ui.primary.map, + &ctx.ui.primary.control_map, + &ctx.ui.primary.road_editor, ctx.new_flags, ) }), - Box::new(|ctx| ctx.ui.ab_test_manager.event(ctx.input, &ctx.ui.map)), + Box::new(|ctx| ctx.ui.ab_test_manager.event(ctx.input, &ctx.ui.primary.map)), Box::new(|ctx| ctx.ui.logs.event(ctx.input)), ], @@ -292,35 +267,92 @@ impl UIWrapper { } } -struct UI { - map: map_model::Map, +// All of the state that's bound to a specific map+edit has to live here. +// TODO How can we arrange the code so that we statically know that we don't pass anything from UI +// to something in PerMapUI? +struct PerMapUI { + map: Map, draw_map: DrawMap, control_map: ControlMap, sim: Sim, - layers: ToggleableLayers, - current_selection: Option, + // Anything that holds onto any kind of ID has to live here! hider: Hider, debug_objects: DebugObjectsState, - search_state: SearchState, - warp: WarpState, follow: FollowState, show_route: ShowRouteState, floodfiller: Floodfiller, steepness_viz: SteepnessVisualizer, - osm_classifier: OsmClassifier, traffic_signal_editor: TrafficSignalEditor, stop_sign_editor: StopSignEditor, road_editor: RoadEditor, - sim_ctrl: SimController, - color_picker: ColorPicker, geom_validator: Validator, turn_cycler: TurnCyclerState, draw_neighborhoods: DrawNeighborhoodState, scenarios: ScenarioManager, edits_manager: EditsManager, +} + +impl PerMapUI { + fn new(flags: SimFlags, kml: Option) -> PerMapUI { + flame::start("setup"); + let (map, control_map, sim) = sim::load(flags.clone(), Some(sim::Tick::from_seconds(30))); + let extra_shapes = if let Some(path) = kml.clone() { + kml::load(&path, &map.get_gps_bounds()).expect("Couldn't load extra KML shapes") + } else { + Vec::new() + }; + + flame::start("draw_map"); + let draw_map = DrawMap::new(&map, &control_map, extra_shapes); + flame::end("draw_map"); + + flame::end("setup"); + flame::dump_stdout(); + + let steepness_viz = SteepnessVisualizer::new(&map); + let road_editor = RoadEditor::new(map.get_road_edits().clone()); + + PerMapUI { + map, + draw_map, + control_map, + sim, + + current_selection: None, + + hider: Hider::new(), + debug_objects: DebugObjectsState::new(), + follow: FollowState::Empty, + show_route: ShowRouteState::Empty, + floodfiller: Floodfiller::new(), + steepness_viz, + traffic_signal_editor: TrafficSignalEditor::new(), + stop_sign_editor: StopSignEditor::new(), + road_editor, + geom_validator: Validator::new(), + turn_cycler: TurnCyclerState::new(), + draw_neighborhoods: DrawNeighborhoodState::new(), + scenarios: ScenarioManager::new(), + edits_manager: EditsManager::new(flags), + } + } +} + +struct UI { + primary: PerMapUI, + // When running an A/B test, this is populated too. + secondary: Option, + + layers: ToggleableLayers, + search_state: SearchState, + warp: WarpState, + osm_classifier: OsmClassifier, + // TODO This one has per-sim state right now, but soon will understand how to handle two sims. + sim_ctrl: SimController, + color_picker: ColorPicker, ab_test_manager: ABTestManager, logs: DisplayLogs, @@ -347,11 +379,11 @@ impl UI { fn mouseover_something(&self) -> Option { let pt = self.canvas.get_cursor_in_map_space(); - let (statics, dynamics) = self.draw_map.get_objects_onscreen( + let (statics, dynamics) = self.primary.draw_map.get_objects_onscreen( self.canvas.get_screen_bbox(), - &self.hider, - &self.map, - &self.sim, + &self.primary.hider, + &self.primary.map, + &self.primary.sim, &self.layers, self, ); @@ -387,13 +419,13 @@ impl UI { // Always handle mouseover if old_zoom >= MIN_ZOOM_FOR_MOUSEOVER && new_zoom < MIN_ZOOM_FOR_MOUSEOVER { - self.current_selection = None; + self.primary.current_selection = None; } if !self.canvas.is_dragging() && input.get_moved_mouse().is_some() && new_zoom >= MIN_ZOOM_FOR_MOUSEOVER { - self.current_selection = self.mouseover_something(); + self.primary.current_selection = self.mouseover_something(); } // TODO Normally we'd return InputOnly here if there was an active plugin, but actually, we @@ -426,7 +458,7 @@ impl UI { if input.unimportant_key_pressed(Key::Escape, ROOT_MENU, "quit") { let state = EditorState { - map_name: self.map.get_name().clone(), + map_name: self.primary.map.get_name().clone(), cam_x: self.canvas.cam_x, cam_y: self.canvas.cam_y, cam_zoom: self.canvas.cam_zoom, @@ -441,10 +473,10 @@ impl UI { // Sim controller plugin is kind of always active? If nothing else ran, let it use keys. let result = self.sim_ctrl.event( &mut input, - &self.map, - &self.control_map, - &mut self.sim, - self.current_selection, + &self.primary.map, + &self.primary.control_map, + &mut self.primary.sim, + self.primary.current_selection, osd, ); input.populate_osd(osd); @@ -454,11 +486,11 @@ impl UI { fn draw(&self, g: &mut GfxCtx, osd: Text) { g.clear(self.cs.get(Colors::Background)); - let (statics, dynamics) = self.draw_map.get_objects_onscreen( + let (statics, dynamics) = self.primary.draw_map.get_objects_onscreen( self.canvas.get_screen_bbox(), - &self.hider, - &self.map, - &self.sim, + &self.primary.hider, + &self.primary.map, + &self.primary.sim, &self.layers, self, ); @@ -473,10 +505,10 @@ impl UI { opts, Ctx { cs: &self.cs, - map: &self.map, - control_map: &self.control_map, + map: &self.primary.map, + control_map: &self.primary.control_map, canvas: &self.canvas, - sim: &self.sim, + sim: &self.primary.sim, }, ); } @@ -491,29 +523,34 @@ impl UI { opts, Ctx { cs: &self.cs, - map: &self.map, - control_map: &self.control_map, + map: &self.primary.map, + control_map: &self.primary.control_map, canvas: &self.canvas, - sim: &self.sim, + sim: &self.primary.sim, }, ); } // TODO Only if active? - self.turn_cycler.draw( - &self.map, - &self.draw_map, - &self.control_map, - &self.sim, + self.primary.turn_cycler.draw( + &self.primary.map, + &self.primary.draw_map, + &self.primary.control_map, + self.primary.sim.time, &self.cs, g, ); - self.debug_objects - .draw(&self.map, &self.canvas, &self.draw_map, &self.sim, g); + self.primary.debug_objects.draw( + &self.primary.map, + &self.canvas, + &self.primary.draw_map, + &self.primary.sim, + g, + ); self.color_picker.draw(&self.canvas, g); - self.draw_neighborhoods.draw(g, &self.canvas); - self.scenarios.draw(g, &self.canvas); - self.edits_manager.draw(g, &self.canvas); + self.primary.draw_neighborhoods.draw(g, &self.canvas); + self.primary.scenarios.draw(g, &self.canvas); + self.primary.edits_manager.draw(g, &self.canvas); self.ab_test_manager.draw(g, &self.canvas); self.logs.draw(g, &self.canvas); self.search_state.draw(g, &self.canvas); @@ -524,7 +561,7 @@ impl UI { } fn color_obj(&self, id: ID) -> Option { - if Some(id) == self.current_selection { + if Some(id) == self.primary.current_selection { return Some(self.cs.get(Colors::Selected)); } @@ -533,10 +570,10 @@ impl UI { id, Ctx { cs: &self.cs, - map: &self.map, - control_map: &self.control_map, + map: &self.primary.map, + control_map: &self.primary.control_map, canvas: &self.canvas, - sim: &self.sim, + sim: &self.primary.sim, }, ) { return Some(c); @@ -553,24 +590,24 @@ impl UI { match idx { // The first plugin is all the ToggleableLayers, which doesn't implement Colorizer. 0 => None, - 1 => Some(Box::new(&self.traffic_signal_editor)), - 2 => Some(Box::new(&self.stop_sign_editor)), - 3 => Some(Box::new(&self.road_editor)), + 1 => Some(Box::new(&self.primary.traffic_signal_editor)), + 2 => Some(Box::new(&self.primary.stop_sign_editor)), + 3 => Some(Box::new(&self.primary.road_editor)), 4 => Some(Box::new(&self.search_state)), 5 => Some(Box::new(&self.warp)), - 6 => Some(Box::new(&self.follow)), - 7 => Some(Box::new(&self.show_route)), + 6 => Some(Box::new(&self.primary.follow)), + 7 => Some(Box::new(&self.primary.show_route)), 8 => Some(Box::new(&self.color_picker)), - 9 => Some(Box::new(&self.steepness_viz)), + 9 => Some(Box::new(&self.primary.steepness_viz)), 10 => Some(Box::new(&self.osm_classifier)), - 11 => Some(Box::new(&self.hider)), - 12 => Some(Box::new(&self.debug_objects)), - 13 => Some(Box::new(&self.floodfiller)), - 14 => Some(Box::new(&self.geom_validator)), - 15 => Some(Box::new(&self.turn_cycler)), - 16 => Some(Box::new(&self.draw_neighborhoods)), - 17 => Some(Box::new(&self.scenarios)), - 18 => Some(Box::new(&self.edits_manager)), + 11 => Some(Box::new(&self.primary.hider)), + 12 => Some(Box::new(&self.primary.debug_objects)), + 13 => Some(Box::new(&self.primary.floodfiller)), + 14 => Some(Box::new(&self.primary.geom_validator)), + 15 => Some(Box::new(&self.primary.turn_cycler)), + 16 => Some(Box::new(&self.primary.draw_neighborhoods)), + 17 => Some(Box::new(&self.primary.scenarios)), + 18 => Some(Box::new(&self.primary.edits_manager)), 19 => Some(Box::new(&self.ab_test_manager)), 20 => Some(Box::new(&self.logs)), _ => panic!("Active plugin {} is too high", idx), @@ -648,8 +685,8 @@ pub trait ShowTurnIcons { impl ShowTurnIcons for UI { fn show_icons_for(&self, id: IntersectionID) -> bool { self.layers.show_all_turn_icons.is_enabled() - || self.stop_sign_editor.show_turn_icons(id) - || self.traffic_signal_editor.show_turn_icons(id) + || self.primary.stop_sign_editor.show_turn_icons(id) + || self.primary.traffic_signal_editor.show_turn_icons(id) } }