Skip to main content

tiled_map_web_viewer/
lib.rs

1use bevy::asset::{AssetMetaCheck, RecursiveDependencyLoadState};
2use bevy::camera::RenderTarget;
3use bevy::prelude::*;
4use bevy::render::render_resource::TextureFormat;
5use bevy_ecs_tiled::prelude::*;
6use bevy_egui::{EguiContexts, EguiTextureHandle};
7use bevy_workbench::console::console_log_layer;
8use bevy_workbench::i18n::{I18n, Locale};
9use bevy_workbench::prelude::*;
10use serde::{Deserialize, Serialize};
11
12use std::collections::HashMap;
13use std::sync::Arc;
14use std::sync::RwLock;
15use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
16
17mod panels;
18
19use panels::{MapDetailsPanel, MapListPanel, MapPreviewPanel};
20
21// --- Public API ---
22
23/// A named group of maps shown as a collapsible section in the map list panel.
24#[derive(Clone, Debug)]
25pub struct MapSection {
26    /// Display name shown in the map list and settings UI.
27    pub name: String,
28    /// Stable key used to match manifest entries.
29    pub key: String,
30    /// Whether this section should be visible by default.
31    pub default_visible: bool,
32}
33
34/// A named group of maps shown inside a section in the map list panel.
35#[derive(Clone, Debug)]
36pub struct MapCategory {
37    /// Display name shown in the list panel.
38    pub name: String,
39    /// Stable key used to match manifest entries.
40    pub key: String,
41}
42
43/// One compact badge rendered next to a map entry.
44#[derive(Clone, Debug, Default, Serialize, Deserialize)]
45pub struct MapBadge {
46    pub label: String,
47    #[serde(default)]
48    pub tone: Option<String>,
49}
50
51/// One detail row shown in the details panel.
52#[derive(Clone, Debug, Default, Serialize, Deserialize)]
53pub struct MapDetail {
54    pub label: String,
55    pub value: String,
56}
57
58/// One map entry loaded from a structured manifest.
59#[derive(Clone, Debug, Default, Serialize, Deserialize)]
60pub struct MapManifestEntry {
61    pub path: String,
62    pub title: String,
63    #[serde(default)]
64    pub section: Option<String>,
65    #[serde(default)]
66    pub category: Option<String>,
67    #[serde(default)]
68    pub badges: Vec<MapBadge>,
69    #[serde(default)]
70    pub details: Vec<MapDetail>,
71}
72
73impl MapManifestEntry {
74    pub fn display_title(&self) -> &str {
75        if self.title.is_empty() {
76            &self.path
77        } else {
78            &self.title
79        }
80    }
81}
82
83/// Top-level manifest file consumed by the viewer.
84#[derive(Clone, Debug, Default, Serialize, Deserialize)]
85pub struct MapManifest {
86    #[serde(default)]
87    pub maps: Vec<MapManifestEntry>,
88}
89
90/// Top-level configuration for the viewer application.
91pub struct ViewerConfig {
92    /// Window title.
93    pub title: String,
94    /// Initial window resolution (width, height).
95    pub resolution: (u32, u32),
96    /// Top-level sections used to organize manifest entries.
97    pub sections: Vec<MapSection>,
98    /// Map categories. When non-empty the map list panel groups maps
99    /// under collapsible headers. When empty, maps fall back to a flat list.
100    pub categories: Vec<MapCategory>,
101    /// Structured manifest path under the web assets root.
102    pub manifest_path: String,
103    /// Additional Fluent locale sources `(locale, ftl_content)` to register.
104    pub locale_sources: Vec<(Locale, &'static str)>,
105}
106
107impl Default for ViewerConfig {
108    fn default() -> Self {
109        Self {
110            title: "Tiled Map Web Viewer".into(),
111            resolution: (1280, 720),
112            sections: vec![],
113            categories: vec![],
114            manifest_path: "assets/manifest.json".into(),
115            locale_sources: vec![],
116        }
117    }
118}
119
120/// Entry point — builds and runs the Bevy application with the given configuration.
121pub fn run(config: ViewerConfig) {
122    let mut app = App::new();
123
124    let dev_toggle = Arc::new(AtomicBool::new(false));
125    let section_toggles = Arc::new(RwLock::new(
126        config
127            .sections
128            .iter()
129            .map(|section| SectionToggle {
130                key: section.key.clone(),
131                name: section.name.clone(),
132                visible: section.default_visible,
133            })
134            .collect::<Vec<_>>(),
135    ));
136    app.add_plugins(
137        DefaultPlugins
138            .set(AssetPlugin {
139                meta_check: AssetMetaCheck::Never,
140                ..default()
141            })
142            .set(WindowPlugin {
143                primary_window: Some(Window {
144                    title: config.title.clone(),
145                    resolution: (config.resolution.0, config.resolution.1).into(),
146                    canvas: Some("#the_canvas_id".to_string()),
147                    fit_canvas_to_parent: true,
148                    prevent_default_event_handling: true,
149                    ..default()
150                }),
151                ..default()
152            })
153            .set(ImagePlugin::default_nearest())
154            .set(bevy::log::LogPlugin {
155                custom_layer: console_log_layer,
156                ..default()
157            }),
158    )
159    .insert_resource(ClearColor(Color::BLACK))
160    .add_plugins(WorkbenchPlugin {
161        config: WorkbenchConfig {
162            show_toolbar: false,
163            enable_game_view: false,
164            ..default()
165        },
166    })
167    .add_plugins(TiledPlugin::default());
168
169    // Register built-in locale FTL sources
170    {
171        let mut i18n = app.world_mut().resource_mut::<I18n>();
172        i18n.add_custom_source(Locale::En, include_str!("../locales/en.ftl"));
173        i18n.add_custom_source(Locale::ZhCn, include_str!("../locales/zh-CN.ftl"));
174        for (locale, ftl) in &config.locale_sources {
175            i18n.add_custom_source(*locale, *ftl);
176        }
177    }
178
179    let shared_translations: SharedTranslations = {
180        let i18n = app.world().resource::<I18n>();
181        Arc::new(RwLock::new(Translations::from_i18n(i18n)))
182    };
183
184    // Store categories as a resource for the map list panel
185    let sections = config.sections.clone();
186    let categories = config.categories.clone();
187    let manifest_path = config.manifest_path.clone();
188
189    app.init_resource::<MapLoadRequest>()
190        .init_resource::<MapLoadingState>()
191        .init_resource::<PreviewInput>()
192        .init_resource::<CameraZoomState>()
193        .init_resource::<DevWindowsEnabled>()
194        .insert_resource(SectionVisibilityState::from_sections(&config.sections))
195        .init_resource::<SelectedMapDetails>()
196        .init_resource::<ScrollSensitivity>()
197        .init_resource::<MenuBarExtensions>()
198        .add_systems(Startup, setup)
199        .add_systems(
200            Update,
201            (
202                handle_map_load,
203                track_map_loading,
204                sync_preview_to_panel,
205                apply_camera_zoom,
206                apply_camera_pan,
207                handle_dev_menu_actions,
208                notify_web_loader_ready,
209            ),
210        )
211        .add_systems(
212            bevy_egui::EguiPrimaryContextPass,
213            sync_dev_menu.before(bevy_workbench::menu_bar::menu_bar_system),
214        );
215
216    // Register panels
217    let t_for_list = shared_translations.clone();
218    app.register_panel(MapListPanel::new(
219        t_for_list,
220        sections,
221        categories,
222        manifest_path,
223    ));
224    let t_for_preview = shared_translations.clone();
225    app.register_panel(MapPreviewPanel::new(t_for_preview));
226    let t_for_details = shared_translations.clone();
227    app.register_panel(MapDetailsPanel::new(t_for_details));
228
229    // Hide Inspector and Console
230    {
231        let mut tile_state = app
232            .world_mut()
233            .resource_mut::<bevy_workbench::dock::TileLayoutState>();
234        tile_state.hide_from_window_menu("workbench_inspector");
235        tile_state.hide_from_window_menu("workbench_console");
236        tile_state.set_default_hidden("workbench_inspector");
237        tile_state.set_default_hidden("workbench_console");
238    }
239
240    // Settings: Developer section
241    let toggle_for_settings = dev_toggle.clone();
242    let t_for_dev_section = shared_translations.clone();
243    app.register_settings_section(SettingsSection {
244        label: shared_translations.read().unwrap().developer_label.clone(),
245        ui_fn: Box::new(move |ui| {
246            let label = t_for_dev_section.read().unwrap().allow_dev_windows.clone();
247            let mut val = toggle_for_settings.load(Ordering::Relaxed);
248            if ui.checkbox(&mut val, label).changed() {
249                toggle_for_settings.store(val, Ordering::Relaxed);
250            }
251        }),
252    });
253
254    if !config.sections.is_empty() {
255        let toggles_for_settings = section_toggles.clone();
256        let t_for_map_section = shared_translations.clone();
257        app.register_settings_section(SettingsSection {
258            label: shared_translations
259                .read()
260                .unwrap()
261                .map_sections_label
262                .clone(),
263            ui_fn: Box::new(move |ui| {
264                let Ok(t) = t_for_map_section.read() else {
265                    return;
266                };
267                ui.label(&t.settings_visible_sections_hint);
268                drop(t);
269
270                let Ok(mut toggles) = toggles_for_settings.write() else {
271                    return;
272                };
273                for toggle in toggles.iter_mut() {
274                    ui.checkbox(&mut toggle.visible, &toggle.name);
275                }
276            }),
277        });
278    }
279
280    // Settings: Sensitivity section
281    let zoom_sens = Arc::new(AtomicU32::new(0.01_f32.to_bits()));
282    let pan_sens = Arc::new(AtomicU32::new(1.0_f32.to_bits()));
283    let zoom_for_settings = zoom_sens.clone();
284    let pan_for_settings = pan_sens.clone();
285    let t_for_sens_section = shared_translations.clone();
286    app.register_settings_section(SettingsSection {
287        label: shared_translations
288            .read()
289            .unwrap()
290            .sensitivity_label
291            .clone(),
292        ui_fn: Box::new(move |ui| {
293            let t = t_for_sens_section.read().unwrap();
294            let mut zoom_val = f32::from_bits(zoom_for_settings.load(Ordering::Relaxed));
295            if ui
296                .add(egui::Slider::new(&mut zoom_val, 0.001..=0.05).text(&t.zoom_sensitivity))
297                .changed()
298            {
299                zoom_for_settings.store(zoom_val.to_bits(), Ordering::Relaxed);
300            }
301
302            let mut pan_val = f32::from_bits(pan_for_settings.load(Ordering::Relaxed));
303            if ui
304                .add(egui::Slider::new(&mut pan_val, 0.1..=3.0).text(&t.pan_sensitivity))
305                .changed()
306            {
307                pan_for_settings.store(pan_val.to_bits(), Ordering::Relaxed);
308            }
309        }),
310    });
311
312    // System to sync atomic values → resources and update translations on locale change
313    let toggle_for_system = dev_toggle.clone();
314    let section_toggles_for_system = section_toggles.clone();
315    let zoom_for_system = zoom_sens.clone();
316    let pan_for_system = pan_sens.clone();
317    let t_for_sync = shared_translations.clone();
318    app.add_systems(
319        Update,
320        move |mut dev_enabled: ResMut<DevWindowsEnabled>,
321              mut section_visibility: ResMut<SectionVisibilityState>,
322              mut sensitivity: ResMut<ScrollSensitivity>,
323              i18n: Res<I18n>| {
324            dev_enabled.0 = toggle_for_system.load(Ordering::Relaxed);
325            section_visibility.0.clear();
326            if let Ok(toggles) = section_toggles_for_system.read() {
327                for toggle in toggles.iter() {
328                    section_visibility
329                        .0
330                        .insert(toggle.key.clone(), toggle.visible);
331                }
332            }
333            sensitivity.zoom = f32::from_bits(zoom_for_system.load(Ordering::Relaxed));
334            sensitivity.pan = f32::from_bits(pan_for_system.load(Ordering::Relaxed));
335
336            if i18n.is_changed()
337                && let Ok(mut t) = t_for_sync.write()
338            {
339                *t = Translations::from_i18n(&i18n);
340            }
341        },
342    );
343
344    app.run();
345}
346
347// --- Internal types ---
348
349#[derive(Clone, Default)]
350#[allow(dead_code)]
351struct Translations {
352    allow_dev_windows: String,
353    zoom_sensitivity: String,
354    pan_sensitivity: String,
355    developer_label: String,
356    sensitivity_label: String,
357    map_sections_label: String,
358    settings_visible_sections_hint: String,
359    dev_windows_menu: String,
360    inspector: String,
361    console: String,
362    map_list: String,
363    map_preview: String,
364    map_details: String,
365    list_loading_maps: String,
366    list_no_maps: String,
367    list_other_group: String,
368    details_no_selection: String,
369    details_path: String,
370    details_section: String,
371    details_category: String,
372    details_badges: String,
373    loading_cleanup: String,
374    loading_textures: String,
375    loading_spawning: String,
376}
377
378impl Translations {
379    fn from_i18n(i18n: &I18n) -> Self {
380        Self {
381            allow_dev_windows: i18n.t("settings-allow-dev-windows"),
382            zoom_sensitivity: i18n.t("settings-zoom-sensitivity"),
383            pan_sensitivity: i18n.t("settings-pan-sensitivity"),
384            developer_label: i18n.t("settings-developer"),
385            sensitivity_label: i18n.t("settings-sensitivity"),
386            map_sections_label: i18n.t("settings-map-sections"),
387            settings_visible_sections_hint: i18n.t("settings-visible-sections-hint"),
388            dev_windows_menu: i18n.t("menu-dev-windows"),
389            inspector: i18n.t("menu-dev-inspector"),
390            console: i18n.t("menu-dev-console"),
391            map_list: i18n.t("panel-map-list"),
392            map_preview: i18n.t("panel-map-preview"),
393            map_details: i18n.t("panel-map-details"),
394            list_loading_maps: i18n.t("list-loading-maps"),
395            list_no_maps: i18n.t("list-no-maps"),
396            list_other_group: i18n.t("list-other-group"),
397            details_no_selection: i18n.t("details-no-selection"),
398            details_path: i18n.t("details-path"),
399            details_section: i18n.t("details-section"),
400            details_category: i18n.t("details-category"),
401            details_badges: i18n.t("details-badges"),
402            loading_cleanup: i18n.t("loading-cleanup"),
403            loading_textures: i18n.t("loading-textures"),
404            loading_spawning: i18n.t("loading-spawning"),
405        }
406    }
407}
408
409pub(crate) type SharedTranslations = Arc<RwLock<Translations>>;
410
411#[derive(Resource, Default)]
412struct MapLoadRequest {
413    map_to_load: Option<String>,
414}
415
416#[derive(Resource, Default)]
417struct MapLoadingState {
418    phase: LoadPhase,
419    current_map: Option<String>,
420    pending_handle: Option<Handle<TiledMapAsset>>,
421    status_text: String,
422}
423
424#[derive(Default, PartialEq)]
425enum LoadPhase {
426    #[default]
427    Idle,
428    Cleanup,
429    LoadingAssets,
430    Spawning,
431}
432
433#[derive(Resource)]
434struct MapPreviewState {
435    render_target: Handle<Image>,
436    egui_texture_id: Option<egui::TextureId>,
437    width: u32,
438    height: u32,
439}
440
441#[derive(Resource, Default)]
442struct PreviewInput {
443    scroll_delta: f32,
444    drag_delta: egui::Vec2,
445    #[allow(dead_code)]
446    hovered: bool,
447    cursor_uv: Option<egui::Pos2>,
448    image_screen_size: egui::Vec2,
449}
450
451#[derive(Resource)]
452struct CameraZoomState {
453    current_scale: f32,
454    target_scale: f32,
455}
456
457impl Default for CameraZoomState {
458    fn default() -> Self {
459        Self {
460            current_scale: 4.0,
461            target_scale: 4.0,
462        }
463    }
464}
465
466#[derive(Resource, Default)]
467struct DevWindowsEnabled(bool);
468
469#[derive(Resource, Default)]
470struct SectionVisibilityState(HashMap<String, bool>);
471
472impl SectionVisibilityState {
473    fn from_sections(sections: &[MapSection]) -> Self {
474        Self(
475            sections
476                .iter()
477                .map(|section| (section.key.clone(), section.default_visible))
478                .collect(),
479        )
480    }
481}
482
483#[derive(Resource, Default)]
484struct SelectedMapDetails(Option<MapManifestEntry>);
485
486#[derive(Clone)]
487struct SectionToggle {
488    key: String,
489    name: String,
490    visible: bool,
491}
492
493#[derive(Resource)]
494struct ScrollSensitivity {
495    zoom: f32,
496    pan: f32,
497}
498
499impl Default for ScrollSensitivity {
500    fn default() -> Self {
501        Self {
502            zoom: 0.01,
503            pan: 1.0,
504        }
505    }
506}
507
508#[derive(Component)]
509struct PreviewCamera;
510
511// --- Systems ---
512
513fn setup(mut commands: Commands, mut images: ResMut<Assets<Image>>) {
514    let width = 1920u32;
515    let height = 1080u32;
516
517    let image = Image::new_target_texture(width, height, TextureFormat::Rgba8UnormSrgb, None);
518    let render_target = images.add(image);
519
520    commands.spawn(Camera2d);
521    commands.spawn((
522        Camera2d,
523        Camera {
524            order: -1,
525            clear_color: ClearColorConfig::Custom(Color::srgb(0.1, 0.1, 0.15)),
526            ..default()
527        },
528        RenderTarget::from(render_target.clone()),
529        PreviewCamera,
530    ));
531
532    commands.insert_resource(MapPreviewState {
533        render_target,
534        egui_texture_id: None,
535        width,
536        height,
537    });
538}
539
540fn sync_dev_menu(
541    dev_enabled: Res<DevWindowsEnabled>,
542    i18n: Res<I18n>,
543    mut extensions: ResMut<MenuBarExtensions>,
544    tile_state: Res<bevy_workbench::dock::TileLayoutState>,
545) {
546    let inspector_visible = tile_state.is_panel_visible("workbench_inspector");
547    let console_visible = tile_state.is_panel_visible("workbench_console");
548
549    let inspector_label = i18n.t("menu-dev-inspector");
550    let console_label = i18n.t("menu-dev-console");
551
552    extensions.custom_menus = vec![CustomMenu {
553        id: "dev_windows",
554        label: i18n.t("menu-dev-windows"),
555        enabled: dev_enabled.0,
556        items: vec![
557            MenuExtItem {
558                id: "toggle_inspector",
559                label: if inspector_visible {
560                    format!("✓ {inspector_label}")
561                } else {
562                    format!("  {inspector_label}")
563                },
564                enabled: true,
565            },
566            MenuExtItem {
567                id: "toggle_console",
568                label: if console_visible {
569                    format!("✓ {console_label}")
570                } else {
571                    format!("  {console_label}")
572                },
573                enabled: true,
574            },
575        ],
576    }];
577}
578
579fn handle_dev_menu_actions(
580    mut menu_actions: MessageReader<MenuAction>,
581    mut tile_state: ResMut<bevy_workbench::dock::TileLayoutState>,
582) {
583    for action in menu_actions.read() {
584        match action.id {
585            "toggle_inspector" => {
586                if tile_state.is_panel_visible("workbench_inspector") {
587                    tile_state.close_panel("workbench_inspector");
588                } else {
589                    tile_state.request_open_panel("workbench_inspector");
590                }
591            }
592            "toggle_console" => {
593                if tile_state.is_panel_visible("workbench_console") {
594                    tile_state.close_panel("workbench_console");
595                } else {
596                    tile_state.request_open_panel("workbench_console");
597                }
598            }
599            _ => {}
600        }
601    }
602}
603
604fn handle_map_load(
605    mut commands: Commands,
606    mut load_request: ResMut<MapLoadRequest>,
607    mut loading: ResMut<MapLoadingState>,
608    i18n: Res<I18n>,
609    existing_maps: Query<Entity, With<TiledMap>>,
610) {
611    let Some(map_name) = load_request.map_to_load.take() else {
612        return;
613    };
614
615    for entity in &existing_maps {
616        commands.entity(entity).despawn();
617    }
618
619    loading.phase = LoadPhase::Cleanup;
620    loading.current_map = Some(map_name);
621    loading.pending_handle = None;
622    loading.status_text = i18n.t("loading-cleanup");
623}
624
625fn track_map_loading(
626    mut commands: Commands,
627    mut loading: ResMut<MapLoadingState>,
628    asset_server: Res<AssetServer>,
629    i18n: Res<I18n>,
630    maps: Query<&Children, With<TiledMap>>,
631) {
632    match loading.phase {
633        LoadPhase::Idle => {}
634        LoadPhase::Cleanup => {
635            if let Some(ref map_name) = loading.current_map.clone() {
636                let handle: Handle<TiledMapAsset> = asset_server.load(map_name);
637                loading.pending_handle = Some(handle);
638                loading.phase = LoadPhase::LoadingAssets;
639                loading.status_text = i18n.t("loading-textures");
640                info!("Loading map: {}", map_name);
641            }
642        }
643        LoadPhase::LoadingAssets => {
644            if let Some(ref handle) = loading.pending_handle {
645                let load_state = asset_server.recursive_dependency_load_state(handle);
646                match load_state {
647                    RecursiveDependencyLoadState::Loaded => {
648                        commands.spawn((TiledMap(handle.clone()), TilemapAnchor::Center));
649                        loading.phase = LoadPhase::Spawning;
650                        loading.status_text = i18n.t("loading-spawning");
651                    }
652                    RecursiveDependencyLoadState::Failed(_) => {
653                        error!("Failed to load map assets");
654                        loading.phase = LoadPhase::Idle;
655                        loading.status_text.clear();
656                    }
657                    _ => {
658                        loading.status_text = i18n.t("loading-textures");
659                    }
660                }
661            }
662        }
663        LoadPhase::Spawning => {
664            for children in &maps {
665                if !children.is_empty() {
666                    loading.phase = LoadPhase::Idle;
667                    loading.status_text.clear();
668                    loading.pending_handle = None;
669                }
670            }
671        }
672    }
673}
674
675fn apply_camera_zoom(
676    mut zoom_state: ResMut<CameraZoomState>,
677    input: Res<PreviewInput>,
678    preview: Res<MapPreviewState>,
679    sensitivity: Res<ScrollSensitivity>,
680    time: Res<Time>,
681    mut camera_q: Query<(&mut Transform, &mut Projection), With<PreviewCamera>>,
682) {
683    let Ok((mut transform, mut projection)) = camera_q.single_mut() else {
684        return;
685    };
686    let Projection::Orthographic(ref mut ortho) = *projection else {
687        return;
688    };
689
690    if input.scroll_delta.abs() > 0.001 {
691        let zoom_factor = 1.0 - input.scroll_delta * sensitivity.zoom;
692        zoom_state.target_scale = (zoom_state.target_scale * zoom_factor).clamp(0.2, 30.0);
693    }
694
695    let lerp_speed = 12.0;
696    let dt = time.delta_secs();
697    let old_scale = zoom_state.current_scale;
698    zoom_state.current_scale +=
699        (zoom_state.target_scale - zoom_state.current_scale) * (1.0 - (-lerp_speed * dt).exp());
700    ortho.scale = zoom_state.current_scale;
701
702    if let Some(uv) = input.cursor_uv
703        && (old_scale - zoom_state.current_scale).abs() > 0.0001
704    {
705        let rt_w = preview.width as f32;
706        let rt_h = preview.height as f32;
707        let cx = uv.x - 0.5;
708        let cy = -(uv.y - 0.5);
709
710        let world_offset_x = cx * rt_w * old_scale;
711        let world_offset_y = cy * rt_h * old_scale;
712        let new_world_offset_x = cx * rt_w * zoom_state.current_scale;
713        let new_world_offset_y = cy * rt_h * zoom_state.current_scale;
714
715        transform.translation.x += world_offset_x - new_world_offset_x;
716        transform.translation.y += world_offset_y - new_world_offset_y;
717    }
718}
719
720fn apply_camera_pan(
721    mut input: ResMut<PreviewInput>,
722    zoom_state: Res<CameraZoomState>,
723    sensitivity: Res<ScrollSensitivity>,
724    preview: Res<MapPreviewState>,
725    mut camera_q: Query<&mut Transform, With<PreviewCamera>>,
726) {
727    let Ok(mut transform) = camera_q.single_mut() else {
728        return;
729    };
730
731    if input.drag_delta.length_sq() > 0.001 {
732        let img_w = input.image_screen_size.x.max(1.0);
733        let img_h = input.image_screen_size.y.max(1.0);
734        let scale_x = preview.width as f32 / img_w * zoom_state.current_scale;
735        let scale_y = preview.height as f32 / img_h * zoom_state.current_scale;
736
737        transform.translation.x -= input.drag_delta.x * scale_x * sensitivity.pan;
738        transform.translation.y += input.drag_delta.y * scale_y * sensitivity.pan;
739    }
740
741    input.scroll_delta = 0.0;
742    input.drag_delta = egui::Vec2::ZERO;
743}
744
745fn sync_preview_to_panel(
746    mut state: ResMut<MapPreviewState>,
747    mut contexts: EguiContexts,
748    mut tile_state: ResMut<bevy_workbench::dock::TileLayoutState>,
749    loading: Res<MapLoadingState>,
750    mut input: ResMut<PreviewInput>,
751    mut images: ResMut<Assets<Image>>,
752) {
753    if state.egui_texture_id.is_none() && state.render_target != Handle::default() {
754        let texture_id = contexts.add_image(EguiTextureHandle::Strong(state.render_target.clone()));
755        state.egui_texture_id = Some(texture_id);
756    }
757
758    if let Some(panel) = tile_state.get_panel_mut::<MapPreviewPanel>("map_preview") {
759        panel.egui_texture_id = state.egui_texture_id;
760        panel.is_loading = loading.phase != LoadPhase::Idle;
761        panel.loading_status = loading.status_text.clone();
762
763        let panel_w = (panel.panel_size.x as u32).max(2);
764        let panel_h = (panel.panel_size.y as u32).max(2);
765        if panel_w > 0
766            && panel_h > 0
767            && (panel_w != state.width || panel_h != state.height)
768            && panel.panel_size.x > 10.0
769        {
770            let new_image =
771                Image::new_target_texture(panel_w, panel_h, TextureFormat::Rgba8UnormSrgb, None);
772            if let Some(img) = images.get_mut(&state.render_target) {
773                *img = new_image;
774            }
775            state.width = panel_w;
776            state.height = panel_h;
777        }
778
779        panel.width = state.width;
780        panel.height = state.height;
781
782        input.scroll_delta += panel.pending_scroll;
783        input.drag_delta += panel.pending_drag;
784        input.hovered = panel.is_hovered;
785        input.cursor_uv = panel.cursor_uv;
786        input.image_screen_size = panel.image_screen_size;
787
788        panel.pending_scroll = 0.0;
789        panel.pending_drag = egui::Vec2::ZERO;
790    }
791}
792
793#[cfg(target_arch = "wasm32")]
794fn notify_web_loader_ready(mut notified: Local<bool>) {
795    if *notified {
796        return;
797    }
798
799    let Some(window) = web_sys::window() else {
800        return;
801    };
802
803    let Ok(event) = web_sys::Event::new("bevy-app-ready") else {
804        return;
805    };
806
807    if window.dispatch_event(&event).is_ok() {
808        *notified = true;
809    }
810}
811
812#[cfg(not(target_arch = "wasm32"))]
813fn notify_web_loader_ready() {}