Skip to main content

atmos/os_lib/layout/
intersection_observer.rs

1//! Intersection Observer 幾何交差計算モジュール (W3C Intersection Observer Level 2)
2//!
3//! ビューポートまたはルート要素に対するターゲット要素の交差判定、
4//! `rootMargin`(px / %)の適用、交差面積比率(`intersectionRatio`)、
5//! およびしきい値(`thresholds`)に基づくコールバック発火判定を提供する。
6
7use alloc::vec::Vec;
8
9/// 幾何矩形 (x, y, width, height)
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct IntersectionRect {
12    pub x: f64,
13    pub y: f64,
14    pub width: f64,
15    pub height: f64,
16}
17
18impl IntersectionRect {
19    pub const fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
20        Self {
21            x,
22            y,
23            width,
24            height,
25        }
26    }
27
28    pub const fn zero() -> Self {
29        Self {
30            x: 0.0,
31            y: 0.0,
32            width: 0.0,
33            height: 0.0,
34        }
35    }
36
37    #[inline]
38    pub fn right(&self) -> f64 {
39        self.x + self.width
40    }
41
42    #[inline]
43    pub fn bottom(&self) -> f64 {
44        self.y + self.height
45    }
46
47    #[inline]
48    pub fn area(&self) -> f64 {
49        self.width.max(0.0) * self.height.max(0.0)
50    }
51
52    /// 2つの矩形の交差部分を算出する
53    pub fn intersect(&self, other: &Self) -> Option<Self> {
54        let x1 = self.x.max(other.x);
55        let y1 = self.y.max(other.y);
56        let x2 = self.right().min(other.right());
57        let y2 = self.bottom().min(other.bottom());
58
59        if x2 >= x1 && y2 >= y1 {
60            Some(Self {
61                x: x1,
62                y: y1,
63                width: x2 - x1,
64                height: y2 - y1,
65            })
66        } else {
67            None
68        }
69    }
70}
71
72/// マージン値 (px または %)
73#[derive(Debug, Clone, Copy, PartialEq)]
74pub struct MarginValue {
75    pub val: f64,
76    pub is_percent: bool,
77}
78
79impl MarginValue {
80    pub const fn px(val: f64) -> Self {
81        Self {
82            val,
83            is_percent: false,
84        }
85    }
86
87    pub const fn percent(val: f64) -> Self {
88        Self {
89            val,
90            is_percent: true,
91        }
92    }
93
94    pub fn resolve(&self, ref_size: f64) -> f64 {
95        if self.is_percent {
96            (self.val / 100.0) * ref_size
97        } else {
98            self.val
99        }
100    }
101
102    fn parse_token(token: &str) -> Result<Self, ()> {
103        let t = token.trim();
104        if let Some(num_str) = t.strip_suffix('%') {
105            let val = num_str.trim().parse::<f64>().map_err(|_| ())?;
106            Ok(Self::percent(val))
107        } else if let Some(num_str) = t.strip_suffix("px") {
108            let val = num_str.trim().parse::<f64>().map_err(|_| ())?;
109            Ok(Self::px(val))
110        } else if let Ok(val) = t.parse::<f64>() {
111            Ok(Self::px(val))
112        } else {
113            Err(())
114        }
115    }
116}
117
118/// `rootMargin` 定義 (top, right, bottom, left)
119#[derive(Debug, Clone, Copy, PartialEq)]
120pub struct RootMargin {
121    pub top: MarginValue,
122    pub right: MarginValue,
123    pub bottom: MarginValue,
124    pub left: MarginValue,
125}
126
127impl RootMargin {
128    pub const fn zero() -> Self {
129        Self {
130            top: MarginValue::px(0.0),
131            right: MarginValue::px(0.0),
132            bottom: MarginValue::px(0.0),
133            left: MarginValue::px(0.0),
134        }
135    }
136
137    /// CSS 形式の margin 文字列をパースする(1〜4値記法)
138    pub fn parse(s: &str) -> Result<Self, ()> {
139        let parts: Vec<&str> = s.split_whitespace().collect();
140        if parts.is_empty() {
141            return Ok(Self::zero());
142        }
143
144        let vals: Vec<MarginValue> = parts
145            .iter()
146            .map(|&p| MarginValue::parse_token(p))
147            .collect::<Result<Vec<_>, _>>()?;
148
149        match vals.len() {
150            1 => Ok(Self {
151                top: vals[0],
152                right: vals[0],
153                bottom: vals[0],
154                left: vals[0],
155            }),
156            2 => Ok(Self {
157                top: vals[0],
158                right: vals[1],
159                bottom: vals[0],
160                left: vals[1],
161            }),
162            3 => Ok(Self {
163                top: vals[0],
164                right: vals[1],
165                bottom: vals[2],
166                left: vals[1],
167            }),
168            4 => Ok(Self {
169                top: vals[0],
170                right: vals[1],
171                bottom: vals[2],
172                left: vals[3],
173            }),
174            _ => Err(()),
175        }
176    }
177
178    /// ルート矩形にマージンを適用して拡大・縮小した `rootBounds` を計算
179    pub fn apply(&self, rect: &IntersectionRect) -> IntersectionRect {
180        let top_px = self.top.resolve(rect.height);
181        let bottom_px = self.bottom.resolve(rect.height);
182        let left_px = self.left.resolve(rect.width);
183        let right_px = self.right.resolve(rect.width);
184
185        IntersectionRect {
186            x: rect.x - left_px,
187            y: rect.y - top_px,
188            width: rect.width + left_px + right_px,
189            height: rect.height + top_px + bottom_px,
190        }
191    }
192}
193
194/// 交差判定結果
195#[derive(Debug, Clone, PartialEq)]
196pub struct IntersectionState {
197    pub bounding_client_rect: IntersectionRect,
198    pub root_bounds: Option<IntersectionRect>,
199    pub intersection_rect: IntersectionRect,
200    pub is_intersecting: bool,
201    pub intersection_ratio: f64,
202}
203
204/// W3C Intersection Observer §3.2 アルゴリズムに基づく交差計算
205pub fn compute_intersection(
206    target_rect: &IntersectionRect,
207    root_rect: &IntersectionRect,
208    root_margin: &RootMargin,
209) -> IntersectionState {
210    let root_bounds = root_margin.apply(root_rect);
211
212    // 境界接触(edge touching)または重なり判定
213    // x軸: target.x <= root_bounds.right() && target.right() >= root_bounds.x
214    // y軸: target.y <= root_bounds.bottom() && target.bottom() >= root_bounds.y
215    let touches_x = target_rect.x <= root_bounds.right() && target_rect.right() >= root_bounds.x;
216    let touches_y = target_rect.y <= root_bounds.bottom() && target_rect.bottom() >= root_bounds.y;
217
218    if touches_x && touches_y {
219        let x1 = target_rect.x.max(root_bounds.x);
220        let y1 = target_rect.y.max(root_bounds.y);
221        let x2 = target_rect.right().min(root_bounds.right());
222        let y2 = target_rect.bottom().min(root_bounds.bottom());
223
224        let w = (x2 - x1).max(0.0);
225        let h = (y2 - y1).max(0.0);
226        let intersection_rect = IntersectionRect::new(x1, y1, w, h);
227        let is_intersecting = true;
228
229        let target_area = target_rect.area();
230        let intersection_ratio = if target_area > 0.0 {
231            (intersection_rect.area() / target_area).clamp(0.0, 1.0)
232        } else {
233            // W3C 仕様: target_area == 0 で接触している場合 ratio は 1.0
234            1.0
235        };
236
237        IntersectionState {
238            bounding_client_rect: *target_rect,
239            root_bounds: Some(root_bounds),
240            intersection_rect,
241            is_intersecting,
242            intersection_ratio,
243        }
244    } else {
245        IntersectionState {
246            bounding_client_rect: *target_rect,
247            root_bounds: Some(root_bounds),
248            intersection_rect: IntersectionRect::zero(),
249            is_intersecting: false,
250            intersection_ratio: 0.0,
251        }
252    }
253}
254
255/// しきい値(thresholds)遷移判定
256/// 前回比率と今回比率の間で thresholds のいずれかを跨いだか、または初回かを判定
257pub fn should_trigger_callback(
258    prev_ratio: Option<f64>,
259    prev_is_intersecting: bool,
260    current_ratio: f64,
261    current_is_intersecting: bool,
262    thresholds: &[f64],
263) -> bool {
264    let Some(prev) = prev_ratio else {
265        // 初回観測時は必ずコールバックを発火
266        return true;
267    };
268
269    // 交差状態(is_intersecting)が変化した場合は threshold 0.0 の遷移とみなす
270    if prev_is_intersecting != current_is_intersecting {
271        return true;
272    }
273
274    if (prev - current_ratio).abs() < 1e-6 {
275        return false;
276    }
277
278    let min_r = prev.min(current_ratio);
279    let max_r = prev.max(current_ratio);
280
281    for &th in thresholds {
282        // 閾値を下から上へ跨いだ、または上から下へ跨いだ
283        if (min_r < th && th <= max_r) || (min_r <= th && th < max_r) {
284            return true;
285        }
286    }
287
288    false
289}