> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# كيف تعرف الخرائط الزحام وسرعة الطريق؟
- URL: https://arabroot.io/articles/كيف-تعرف-الخرائط-الزحام-وسرعة-الطريق/
- Published: 2026-10-09T14:15:00.000Z
- Updated: 2026-10-09T13:00:49.000Z
- Description: كيف تتحول نقاط GPS من الهواتف إلى سرعة لكل شارع وزمن وصول أدق، وما الذي يحمي خصوصية أصحابها.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, مجالات متخصصة, تطبيقات لفريقك, السيادة الرقمية والامتثال, الاستضافة الذاتية

في مقال [حساب أقصر طريق وزمن الوصول مع OSRM وValhalla](https://arabroot.io/articles/%D8%AD%D8%B3%D8%A7%D8%A8-%D8%A7%D9%82%D8%B5%D8%B1-%D8%B7%D8%B1%D9%8A%D9%82-%D9%88%D8%B2%D9%85%D9%86-%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-%D9%85%D8%B9-osrm-%D9%88-valhalla/) قال المحرك إن الطريق من قلعة المصمك إلى مطار الملك خالد يحتاج 34.6 دقيقة، وهذا رقم صحيح لطرق فارغة، ولكن من يسكن الرياض يعرف أن الرحلة نفسها في الثامنة صباحاً شيء آخر. والسبب أن محرك المسارات لا يعرف إلا السرعة الافتراضية لكل شارع، أما تطبيقات مثل Google Maps وWaze فتعرف أن هذا الشارع بطيء الآن، وتغير لونه إلى الأحمر، وتعيد حساب طريقك. فمن أين تأتي هذه المعرفة؟ والجواب المختصر: من هواتف الناس وسياراتهم، والجواب الكامل فيه رياضيات وخصوصية وقصتان حقيقيتان سوف نقرؤهما.

وهذا هو المقال الرابع في مرحلة [الخدمات المتخصصة: الخرائط والبث المباشر ومعالجة الوسائط](https://arabroot.io/start/#stage-special)، وسوف نناقش فيه ما يلي:

- بيانات المسبار Probe Data: كيف تتحول نقاط GPS من الهواتف والأساطيل إلى سرعة لكل شارع.
- مطابقة النقاط مع الطريق Map Matching بنموذج ماركوف المخفي Hidden Markov Model، ولماذا لا يكفي أقرب شارع.
- الزحام التاريخي والزحام اللحظي، وما نشرته Google عن طريقتها في توقع زمن الوصول، وبلاغات مستخدمي Waze.
- الخصوصية: لماذا تعتبر نقاط GPS بيانات شخصية، وحادثة خريطة Strava سنة 2018، ومن يستطيع أن يخدع خريطة الزحام.
- مختبر كامل ببيانات اصطناعية: سيارات وهمية على شوارع الرياض الحقيقية، نطابقها مع OSRM، ونحسب سرعة كل شارع، ونرسم خريطة الزحام، ثم نعيد السرعات إلى المحرك ونرى زمن الوصول يتغير.

## بيانات المسبار Probe Data

الطريقة القديمة لقياس الزحام هي حساسات ثابتة على الطريق، مثل حلقات مغناطيسية تحت الأسفلت أو كاميرات تعد السيارات، وهي دقيقة في مكانها ولكنها مكلفة ولا تغطي إلا الطرق الكبيرة. والطريقة التي تعتمد عليها تطبيقات الخرائط اليوم اسمها بيانات المسبار Probe Data أو بيانات السيارات المتحركة Floating Car Data: كل هاتف يفتح تطبيق الملاحة، وكل سيارة في أسطول توصيل أو أجرة فيها جهاز تتبع، ترسل موقعها كل بضع ثوان، فتصبح كل سيارة «مسباراً» يقيس سرعة الشارع الذي هو فيه دون أن يقصد. والصورة التالية تبين المسار من النقطة إلى لون الشارع:

![مخطط من أربع خطوات: هواتف وسيارات أساطيل ترسل خط العرض وخط الطول والوقت كل بضع ثوان، ثم مطابقة النقاط المشوشة مع الطريق الذي سارت عليه السيارة غالباً، ثم سرعة لكل قطعة طريق من عدة مركبات، كل مركبة أمتار مقسومة على ثوان، والوسيط 53 كم في الساعة من 6 مركبات والسرعة الحرة 56، ولا تظهر إلا إذا مرت 5 مركبات على الأقل، ثم الاستخدامات: ألوان الزحام على الخريطة، وأوزان جديدة لمحرك المسارات بزمن وصول 10.9 دقيقة، وتاريخ الأسابيع الماضية مع قراءات الدقائق الأخيرة](https://arabroot.io/content/images/2026/10/traffic-data-01-probes.webp)

من نقاط GPS إلى سرعة لكل قطعة شارع، ثم إلى الخريطة وزمن الوصول

و[مقال Google في سبتمبر 2020](https://blog.google/products/maps/google-maps-101-how-ai-helps-predict-traffic-and-determine-routes/?ref=arabroot.io) يصف المصدر نفسه بكلماته: بيانات مواقع مجمعة Aggregated ومجهولة الهوية Anonymized من السائقين الذين يستخدمون التطبيق، ويذكر أن السائقين يقطعون مع Google Maps أكثر من مليار كيلومتر يومياً في أكثر من 220 دولة ومنطقة. وهذا الرقم هو سر الفكرة: لكي تعرف سرعة شارع فرعي في حي ما الآن تحتاج أن تمر عليه عدة سيارات تحمل التطبيق في الدقائق الأخيرة، وهذا لا يتحقق إلا بعدد هائل من المستخدمين، لذلك تبقى خرائط الزحام ميزة لمن يملك المستخدمين قبل أن تكون ميزة لمن يملك الخوارزمية.

## مطابقة النقاط مع الطريق Map Matching

نقطة GPS من الهاتف ليست على الشارع، فدقتها في المدينة بضعة أمتار في أحسن الأحوال، وأسوأ بين الأبراج وتحت الجسور. والطريقة البسيطة هي أن تربط كل نقطة بأقرب شارع إليها، ولكنها تفشل في أشهر حالة في مدننا: طريق رئيسي بجانبه طريق خدمة يوازيه على بعد عشرين متراً، فتقفز السيارة في حسابك بين الطريقين مع كل نقطة، وتحسب لطريق الخدمة سرعة الطريق الرئيسي. والحل المعتمد منذ [ورقة Newson وKrumm سنة 2009](https://www.microsoft.com/en-us/research/wp-content/uploads/2016/12/map-matching-ACM-GIS-camera-ready.pdf?ref=arabroot.io) من Microsoft Research هو أن تنظر إلى النقاط كسلسلة، وتسأل: ما سلسلة المواقع على الطرق التي تفسر كل هذه النقاط معاً بأعلى احتمال؟ والصورة التالية تبين الفكرة على أربع نقاط:

![مخطط من ثلاثة أجزاء: أربع نقاط GPS من p1 إلى p4 بين طريق رئيسي وطريق خدمة يوازيه، ولكل نقطة موقعان مرشحان على الطريقين، والنقطة p2 أقرب إلى طريق الخدمة. ثم احتمالان لكل مرشح: احتمال الانبعاث emission وهو منحنى جرسي على المسافة بين النقطة والمرشح وعرضه دقة GPS، واحتمال الانتقال transition الذي يقارن المسافة على شبكة الطرق بين مرشحين بالمسافة المستقيمة بين النقطتين، والانتقال من الرئيسي إلى الخدمة ثم إلى الرئيسي يحتاج التفافين فهو مستبعد. ثم شبكة المرشحين وخوارزمية Viterbi تختار السلسلة الأعلى احتمالاً وهي الطريق الرئيسي طوال الوقت](https://arabroot.io/content/images/2026/10/traffic-data-02-hmm.webp)

أقرب شارع لكل نقطة يخطئ، والسلسلة الأكثر احتمالاً تصيب

- **احتمال الانبعاث Emission Probability:** لكل نقطة عدة مواقع مرشحة Candidates على الطرق القريبة، وكلما كان المرشح أقرب إلى النقطة كان أكثر احتمالاً، بتوزيع طبيعي Gaussian عرضه هو دقة GPS، وهذا ما يحدده الخيار `radiuses` في OSRM كما سوف نرى.
- **احتمال الانتقال Transition Probability:** بين مرشحين لنقطتين متتاليتين نحسب المسافة على شبكة الطرق ونقارنها بالمسافة المستقيمة بين النقطتين، فإذا كانت متقاربة فالانتقال معقول، وإذا احتاج الانتقال التفافاً طويلاً للخروج إلى طريق الخدمة والعودة فهو مستبعد.
- **خوارزمية Viterbi:** تمر على النقاط بالترتيب وتحفظ لكل مرشح أفضل سلسلة تصل إليه، فتجد في النهاية السلسلة الأعلى احتمالاً دون أن تجرب كل التوافيق، وهذا وصف [توثيق Meili في Valhalla](https://valhalla.github.io/valhalla/contributing/architecture/meili/algorithms/?ref=arabroot.io) للخوارزمية نفسها.

وفي OSRM هذه الخدمة اسمها `match` وموثقة في [توثيق HTTP](https://github.com/Project-OSRM/osrm-backend/blob/v26.10.0/docs/http.md?ref=arabroot.io): ترسل النقاط مع أوقاتها، فتعيد الطريق المطابق مع درجة ثقة Confidence بين 0 و1، وتستبعد النقاط الشاذة Outliers التي لا تفسرها أي سلسلة معقولة، وتقسم الرحلة إذا كانت بين نقطتين فجوة زمنية أكبر من 60 ثانية.

## الزحام التاريخي والزحام اللحظي

بعد المطابقة تعرف لكل سيارة متى دخلت كل قطعة شارع ومتى خرجت منها، فسرعتها على القطعة هي الطول مقسوماً على الزمن، وسرعة القطعة هي وسيط سرعات كل السيارات التي مرت عليها في نافذة زمنية، مثل آخر خمس دقائق. ومن هنا يخرج نوعان من البيانات تبينهما الصورة التالية:

![رسم توضيحي لسرعة شارع واحد خلال ساعات يوم عمل: منحنى أزرق للسرعة المعتادة يهبط في الصباح وبعد العصر، ونقاط حمراء للقراءات اللحظية اليوم وهي أبطأ من المعتاد عند الساعة الحالية، وخط متقطع للتوقع بعد 20 دقيقة. وفي الجانب: الملف التاريخي يتعلم من اليوم نفسه في الأسابيع الماضية، والقراءات اللحظية من آخر دقائق، والتوقع لأنك تصل إلى الشارع بعد 20 دقيقة، والقطع القريبة تعتمد على القراءة اللحظية والبعيدة على التاريخ](https://arabroot.io/content/images/2026/10/traffic-data-03-history-live.webp)

التاريخ يقول ما يحدث عادة، والقراءات اللحظية تقول ما يحدث الآن، وزمن الوصول يحتاج ما سوف يحدث

- **الزحام التاريخي Historical Traffic:** متوسط السرعة لكل قطعة في كل ربع ساعة من كل يوم في الأسبوع، يتعلم من أسابيع ماضية. ومقال Google المذكور يضرب مثالاً بطريق 280 في كاليفورنيا: 65 ميلاً في الساعة عادة بين السادسة والسابعة صباحاً، و15 إلى 20 ميلاً فقط في آخر العصر، ويذكر أن الشركة غيرت نماذجها في 2020 لتعطي وزناً أكبر لآخر أسبوعين إلى أربعة أسابيع عندما انخفضت حركة السير في العالم بما يصل إلى 50% في بداية الإغلاق.
- **الزحام اللحظي Live Traffic:** ما تقوله السيارات على القطعة في الدقائق الأخيرة، وهو يكشف ما لا يعرفه التاريخ مثل حادث أو إغلاق.
- **التوقع Prediction:** القطعة التي سوف تصل إليها بعد عشرين دقيقة لا تهمك سرعتها الآن، وإنما سرعتها عندما تصل، وهنا تحتاج نموذجاً يجمع الاثنين.

وفي [مقال DeepMind في 3 سبتمبر 2020](https://deepmind.google/discover/blog/traffic-prediction-with-advanced-graph-neural-networks/?ref=arabroot.io) أن الشركة قسمت شبكة الطرق إلى «قطع كبرى» Supersegments من قطع متجاورة تتشارك حركة سير كبيرة، واستخدمت شبكات عصبية للرسوم البيانية Graph Neural Networks تتعلم من بنية الشبكة نفسها، فتتوقع التأخير عند الانعطاف والاندماج وفي السير المتقطع، وأن هذا حسن دقة زمن الوصول اللحظي بما يصل إلى 50% في مدن مثل برلين وجاكرتا وساو باولو وسيدني وطوكيو وواشنطن، و51% في تايتشونغ. والتفاصيل التقنية في [ورقة ETA Prediction with Graph Neural Networks in Google Maps](https://arxiv.org/abs/2108.11482?ref=arabroot.io) المنشورة في مؤتمر CIKM سنة 2021\. ومقال Google يذكر أن توقعاتها كانت أصلاً دقيقة باستمرار لأكثر من 97% من الرحلات، وأن الشراكة مع DeepMind خفضت نسبة الأزمنة غير الدقيقة أكثر، وهذه أرقام الشركة عن نفسها ولا نستطيع التحقق منها، ولكنها تبين أن الفرق بين التطبيقات اليوم في البيانات والنموذج، وليس في خوارزمية أقصر مسار.

## بلاغات Waze: عندما يكتب السائق ما يراه

السرعة وحدها تقول إن الشارع بطيء، ولا تقول لماذا. وWaze بنى نصف قيمته على أن السائق نفسه يبلغ عما يراه، و[صفحة Data Feed في مساعدة Waze](https://support.google.com/waze/partners/answer/10618035?ref=arabroot.io) تعدد أنواع البلاغات: الحوادث، والمخاطر، والأعمال على الطريق، والحفر، والحيوانات النافقة، والسيارات المتوقفة، والأجسام على الطريق، واللوحات المفقودة. وتذكر الصفحة نفسها ثلاثة تفاصيل تهم من يبني نظاماً مشابهاً:

- الاختناقات Jams يكتشفها Waze آلياً بمقارنة حالة الطريق الحالية بالبيانات التاريخية، أي بالطريقة نفسها التي شرحناها، والبلاغ يضيف السبب.
- لكل بلاغ درجة موثوقية Reliability من 0 إلى 10 تعتمد على تفاعل المستخدمين وخبرة المبلغ، ودرجة ثقة Confidence من 1- إلى 5 تعتمد فقط على تفاعل السائقين اللحظي، مثل التأكيد أو الضغط على «غير موجود»، لأن بلاغاً واحداً قد يكون خطأ أو مزحة.
- الجهات الحكومية تستطيع الاشتراك في [برنامج Waze for Cities](https://www.waze.com/wazeforcities/?ref=arabroot.io) مجاناً، فتأخذ هذه البلاغات وتعطي Waze إغلاقات الطرق والفعاليات المخطط لها.

## نقاط GPS بيانات شخصية

قد يتساءل البعض: إذا كانت الشركة تحذف الاسم ورقم الهاتف فما المشكلة في بقية النقاط؟ والإجابة أن الرحلة نفسها هي الهوية، فرحلة تبدأ كل صباح من البيت نفسه وتنتهي في المكتب نفسه تعرف صاحبها دون اسم. لذلك تمر البيانات بخطوات قبل أن تصبح «زحاماً» تبينها الصورة التالية:

![مخطط من أربع خطوات: رحلة خام واحدة بالأحمر من البيت إلى العمل مع معرف الجهاز كل صباح وهي شخص وليست زحاماً، ثم القص والنسيان بحذف أول الرحلة وآخرها وحذف معرف الجهاز والاحتفاظ بقطع قصيرة لوقت قصير، ثم التجميع بسرعة واحدة لكل قطعة طريق في نافذة 5 أو 15 دقيقة مع عدد المركبات المختلفة k، ثم النشر فقط إذا كان k خمسة أو أكثر لأن سرعة قطعة مرت عليها مركبة أو اثنتان تصف أصحابها، وفي المختبر ظهرت 4060 قطعة واختفت 24451](https://arabroot.io/content/images/2026/10/traffic-data-04-privacy.webp)

الرحلة الخام تصف شخصاً، والسرعة المجمعة من خمس مركبات أو أكثر تصف شارعاً

- **قص الأطراف:** حذف أول الرحلة وآخرها، لأنهما البيت والعمل، وحذف معرف الجهاز أو تغييره كل فترة، وتقسيم الرحلة إلى قطع قصيرة لا يمكن ربطها ببعض.
- **التجميع Aggregation:** ما يخرج من النظام هو سرعة القطعة في نافذة زمنية، وليس مسار أي سيارة.
- **الحد الأدنى للعدد:** وهو تطبيق لفكرة [إخفاء الهوية بعدد k K-Anonymity من Latanya Sweeney سنة 2002](https://dataprivacylab.org/dataprivacy/projects/kanonymity/kanonymity.pdf?ref=arabroot.io): لا تنشر قيمة إلا إذا كانت تخص k أشخاص مختلفين على الأقل، فلا يتميز أحدهم عن البقية. وفي المختبر جعلنا k خمسة، فاختفى أغلب الشوارع الصغيرة، وهذا هو الثمن الحقيقي للخصوصية: خريطة أفقر في الأحياء.

📌

في نوفمبر 2017 نشرت شركة Strava، وهي تطبيق لتسجيل الجري وركوب الدراجة، «خريطة حرارية» Heatmap عالمية جمعت فيها نشاط مستخدميها بين 2015 وسبتمبر 2017، من مليار نشاط وقرابة ثلاثة تريليونات نقطة GPS بحسب [تقرير BBC](https://www.bbc.com/news/technology-42853072?ref=arabroot.io). وفي نهاية يناير 2018 لاحظ الطالب الأسترالي Nathan Ruser أن الخريطة تكشف قواعد عسكرية في سوريا وأفغانستان ودول أخرى، لأن مستخدمي التطبيق الوحيدين في تلك الصحاري هم الجنود، فرسمت مسارات جريهم حدود القواعد وطرقها الداخلية، كما نقلت [The Guardian](https://www.theguardian.com/world/2018/jan/28/fitness-tracking-app-gives-away-location-of-secret-us-army-bases?ref=arabroot.io). البيانات كانت «مجمعة» فعلاً، ولكن التجميع لا يحمي أحداً في مكان لا يوجد فيه إلا عدد قليل من الناس، وهذا بالضبط ما يمنعه الحد الأدنى للعدد k.

## هل يمكن خداع خريطة الزحام؟

بيانات المسبار تفترض أن كل هاتف يتحرك ببطء على الطريق هو سيارة عالقة في الزحام، وهذا الافتراض كسره الفنان Simon Weckert المقيم في برلين في عمل سماه [Google Maps Hacks](https://simonweckert.com/googlemapshacks.html?ref=arabroot.io) سنة 2020: وضع 99 هاتفاً مستعملاً في عربة يد صغيرة ومشى بها في شوارع برلين، فظهرت على Google Maps اختناقات حمراء في شوارع شبه فارغة، وكتب أن هذا يحول الشارع الأخضر إلى أحمر ويجعل السيارات تسلك طريقاً آخر، كما في [تقرير The Guardian](https://www.theguardian.com/technology/2020/feb/03/berlin-artist-uses-99-phones-trick-google-maps-traffic-jam-alert?ref=arabroot.io). والدرس لمن يبني نظاماً مشابهاً أن عدد الأجهزة وحده ليس دليلاً، فتحتاج قواعد تكشف ما لا يشبه السيارات، مثل عدد كبير من الأجهزة يتحرك متلاصقاً بسرعة المشي، أو أجهزة جديدة بلا تاريخ، أو بطء لا تؤكده بيانات أخرى، وهذه قواعد ليس لها نهاية لأن من يريد الخداع سوف يتكيف معها.

## التجربة العملية: زحام اصطناعي على شوارع الرياض

⚠️

كل السيارات والرحلات ونقاط GPS والسرعات في هذا القسم اصطناعية Synthetic، ولدها برنامج من مسارات OSRM على شوارع حقيقية، ولا تمثل أي شخص أو مركبة ولا حالة الزحام الحقيقية في الرياض، والمنطقة البطيئة اخترناها نحن.

لا نملك هواتف آلاف الناس، ولا يجوز أن نستخدم بياناتهم في مقال، لذلك سوف نصنع سيارات وهمية بأنفسنا، ونعرف «الحقيقة» التي صنعناها، ثم نرى هل يستطيع النظام أن يكتشفها من النقاط وحدها. ونكمل على مختبر [مقال المسارات](https://arabroot.io/articles/%D8%AD%D8%B3%D8%A7%D8%A8-%D8%A7%D9%82%D8%B5%D8%B1-%D8%B7%D8%B1%D9%8A%D9%82-%D9%88%D8%B2%D9%85%D9%86-%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-%D9%85%D8%B9-osrm-%D9%88-valhalla/#lab): ال Volume `lab-routing-data`، والشبكة `lab-routing-net`، والخادم `lab-routing-osrm-mld`، وصفحة الخريطة على nginx، والملفات في مجلد `traffic` بجانب مجلد `web`.

### صنع رحلات اصطناعية

الملف التالي يختار نقطتين عشوائيتين في وسط الرياض، ويطلب من OSRM المسار بينهما مع طول كل قطعة وزمنها، ثم «يقود» السيارة على المسار بسرعة الطريق الحر، إلا داخل مستطيل اخترناه في حي العليا فيقود بـ 35% منها، ويسجل نقطة GPS كل خمس ثوان ويضيف إليها تشويشاً عشوائياً بانحراف معياري ثمانية أمتار:

```javascript
// simulate.mjs: SYNTHETIC GPS traces of cars driving real Riyadh streets (no real person or vehicle).
// 1) ask OSRM for a route between two random points, 2) drive it second by second with a "true" speed that we
//    choose ourselves (slower inside a made-up congested zone), 3) keep one GPS fix every 5 s with random noise.
import { writeFileSync } from 'node:fs';
const OSRM = process.env.OSRM || 'http://lab-routing-osrm-mld:5000';
// one retry if the server closed a kept-alive socket
const get = (u) => fetch(u).then(r => r.json()).catch(() => fetch(u).then(r => r.json()));
const TRIPS = 400, EVERY = 5, NOISE_M = 8;                 // vehicles, seconds between fixes, GPS noise (1 sigma, m)
const ZONE = { w: 46.660, s: 24.680, e: 46.700, n: 24.730 }; // the made-up congested zone (Olaya area)
const ZONE_FACTOR = 0.35;                                   // cars in the zone drive at 35% of free-flow speed

let seed = 7; const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
const gauss = () => Math.sqrt(-2 * Math.log(rnd() + 1e-12)) * Math.cos(2 * Math.PI * rnd());
const pt = () => [46.60 + rnd() * 0.20, 24.62 + rnd() * 0.20];
const inZone = ([x, y]) => x > ZONE.w && x < ZONE.e && y > ZONE.s && y < ZONE.n;
const M_PER_DEG_LAT = 111320, M_PER_DEG_LON = 111320 * Math.cos(24.7 * Math.PI / 180);

const traces = [];
const t0 = Date.UTC(2026, 9, 4, 5, 0, 0) / 1000;            // a Sunday morning, 08:00 Riyadh time
for (let v = 0; v < TRIPS; v++) {
  const [a, b] = [pt(), pt()];
  const url = `${OSRM}/route/v1/driving/${a};${b}?overview=full&geometries=geojson&annotations=distance,duration`;
  const r = (await get(url)).routes?.[0];
  if (!r || r.distance < 3000) continue;                    // skip very short trips
  const coords = r.geometry.coordinates, ann = r.legs[0].annotation;
  // drive segment by segment; the true speed is OSRM's free-flow speed, slowed down inside the zone
  let t = t0 + Math.floor(rnd() * 3600), next = t, fixes = [];
  for (let i = 0; i < ann.distance.length; i++) {
    const [p, q] = [coords[i], coords[i + 1]];
    const free = ann.distance[i] / Math.max(ann.duration[i], 0.1);   // m/s
    const speed = free * (inZone(p) ? ZONE_FACTOR : 1) * (0.9 + 0.2 * rnd());
    const dt = ann.distance[i] / speed;
    while (next <= t + dt) {                                // a GPS fix falls inside this segment
      const f = (next - t) / dt;
      const x = p[0] + (q[0] - p[0]) * f + gauss() * NOISE_M / M_PER_DEG_LON;
      const y = p[1] + (q[1] - p[1]) * f + gauss() * NOISE_M / M_PER_DEG_LAT;
      fixes.push([+x.toFixed(6), +y.toFixed(6), Math.round(next)]);
      next += EVERY;
    }
    t += dt;
  }
  traces.push({ vehicle: `synthetic-${String(v).padStart(3, '0')}`, fixes });
}
writeFileSync('/w/traces.json', JSON.stringify(traces));
const n = traces.reduce((s, x) => s + x.fixes.length, 0);
console.log(`synthetic vehicles: ${traces.length}, GPS fixes: ${n}, one fix every ${EVERY} s, noise ${NOISE_M} m`);
console.log('first fixes of', traces[0].vehicle, JSON.stringify(traces[0].fixes.slice(0, 3)));
```

```bash
docker run --rm --name lab-routing-sim --network lab-routing-net -v "$PWD/traffic:/w" node:22-alpine node /w/simulate.mjs
```

والمخرج سوف يكون كما يلي:

```text
synthetic vehicles: 393, GPS fixes: 85453, one fix every 5 s, noise 8 m
first fixes of synthetic-000 [[46.600098,24.803939,1791092625],[46.600374,24.80386,1791092630],[46.600553,24.803586,1791092635]]
```

أي 393 سيارة، لأن البرنامج يتجاهل الرحلات الأقصر من ثلاثة كيلومترات، و85,453 نقطة، وكل نقطة خط الطول ثم خط العرض ثم الوقت بالثواني منذ 1970، والوقت يبدأ صباح الأحد 4 أكتوبر 2026 في الساعة الثامنة بتوقيت الرياض.

### مطابقة الرحلات مع OSRM

الملف التالي يرسل كل رحلة إلى خدمة `match` على دفعات من مئة نقطة، لأن هذا هو الحد الافتراضي `--max-matching-size` في `osrm-routed`، مع الأوقات و`radiuses` بعشرة أمتار لكل نقطة، ويطلب `annotations` حتى يعرف أرقام نقاط OpenStreetMap لكل قطعة. ثم يحسب لكل سيارة سرعتها على كل قطعة من الوقت الحقيقي بين نقطتيها، ويأخذ وسيط السيارات:

```javascript
// match.mjs: map-match the synthetic traces with OSRM, then estimate a speed for every road segment.
// A segment is one piece of road between two OSM nodes (from_osm_id -> to_osm_id, in the driving direction).
import { readFileSync, writeFileSync } from 'node:fs';
const OSRM = process.env.OSRM || 'http://lab-routing-osrm-mld:5000';
// one retry if the server closed a kept-alive socket
const get = (u) => fetch(u).then(r => r.json()).catch(() => fetch(u).then(r => r.json()));
const CHUNK = 100;                                       // osrm-routed accepts up to 100 points per match request by default
const traces = JSON.parse(readFileSync('/w/traces.json', 'utf8'));

const seg = new Map();                                   // "from,to" -> { free, perVehicle: Map(vehicle -> [meters, seconds]) }
let requests = 0, matched = 0, dropped = 0, confidence = [];
for (const { vehicle, fixes } of traces) {
  for (let c = 0; c < fixes.length - 1; c += CHUNK) {
    const part = fixes.slice(c, c + CHUNK);
    if (part.length < 2) break;
    const url = `${OSRM}/match/v1/driving/${part.map(f => `${f[0]},${f[1]}`).join(';')}` +
      `?timestamps=${part.map(f => f[2]).join(';')}&radiuses=${part.map(() => 10).join(';')}` +
      `&annotations=nodes,distance,duration&overview=false`;
    const j = await get(url); requests++;
    if (j.code !== 'Ok') { dropped += part.length; continue; }
    j.tracepoints.forEach(tp => tp ? matched++ : dropped++);
    j.matchings.forEach((m, mi) => {
      confidence.push(m.confidence);
      // the fixes that belong to this matching, in order: leg k joins fix k and fix k+1
      const idx = j.tracepoints.map((tp, i) => tp && tp.matchings_index === mi ? [tp.waypoint_index, i] : null)
        .filter(Boolean).sort((a, b) => a[0] - b[0]).map(x => x[1]);
      m.legs.forEach((leg, k) => {
        const dt = part[idx[k + 1]][2] - part[idx[k]][2];
        if (!(dt > 0) || leg.distance < 1) return;
        const { nodes, distance, duration } = leg.annotation;
        for (let i = 0; i < distance.length; i++) {
          const key = `${nodes[i]},${nodes[i + 1]}`;
          if (!seg.has(key)) seg.set(key, { free: 0, freeFrom: 0, perVehicle: new Map() });
          const e = seg.get(key), s = e.perVehicle;
          // OSRM's own free-flow speed, taken from the longest piece seen (the ends of a leg are partial segments)
          if (distance[i] > e.freeFrom && duration[i] > 0) Object.assign(e, { free: distance[i] / duration[i], freeFrom: distance[i] });
          const [m0, t0] = s.get(vehicle) || [0, 0];
          // this leg's time is shared between its segments in proportion to their length
          s.set(vehicle, [m0 + distance[i], t0 + dt * distance[i] / leg.distance]);
        }
      });
    });
  }
}
confidence.sort((a, b) => a - b);
console.log(`match requests: ${requests}, fixes matched: ${matched}, fixes dropped as outliers: ${dropped}`);
console.log(`median matching confidence: ${confidence[Math.floor(confidence.length / 2)].toFixed(2)}`);

// one speed per vehicle per segment, then the median over vehicles
const rows = [];
for (const [key, { free, perVehicle }] of seg) {
  if (!free) continue;
  const v = [...perVehicle.values()].map(([m, t]) => m / t * 3.6).sort((a, b) => a - b);
  rows.push({ key, vehicles: v.length, speed: v[Math.floor(v.length / 2)], free: free * 3.6 });
}
writeFileSync('/w/segments.json', JSON.stringify(rows));
writeFileSync('/w/node-ids.txt', [...new Set(rows.flatMap(r => r.key.split(',')))].map(n => `n${n}`).join('\n') + '\n');
console.log(`road segments seen: ${rows.length}`);
```

```bash
docker run --rm --name lab-routing-match --network lab-routing-net -v "$PWD/traffic:/w" node:22-alpine node /w/match.mjs
```

```text
match requests: 1046, fixes matched: 85049, fixes dropped as outliers: 402
median matching confidence: 0.97
road segments seen: 28511
```

في الإعداد أعلاه لاحظ التالي:

- استبعد OSRM 402 نقطة من 85,451 كشواذ، أي أقل من نصف بالمئة، والسبب أن التشويش الذي أضفناه ثمانية أمتار فقط، وفي بيانات هواتف حقيقية بين الأبراج سوف ترى نسبة أكبر.
- الوقت على القطعة لا يأتي من OSRM، فالمحرك يعطي الطول والزمن الحر فقط، وإنما من الفرق بين وقتي النقطتين، ونوزعه على القطع بين النقطتين بحسب أطوالها، وهذا تبسيط يفترض أن السيارة سارت بسرعة ثابتة بين نقطتين بينهما خمس ثوان.
- القطعة معرفة برقمي نقطتي OpenStreetMap في اتجاه السير، فالشارع ذو الاتجاهين قطعتان، ولكل اتجاه سرعته، وهذا مهم لأن الزحام كثيراً ما يكون في اتجاه واحد.

والصورة التالية رحلة واحدة من الرحلات الاصطناعية، النقاط الحمراء كما «أرسلها الهاتف» والخط الأزرق ما أعاده `match`، من صفحة `trace.html` التي نعرض كودها في آخر القسم:

![خريطة لجزء من غرب العليا عليها 60 نقطة حمراء لمركبة اصطناعية اسمها synthetic-000 على طريق واحد، والنقاط متباعدة في الشمال ومتقاربة جداً في الجنوب حيث دخلت المنطقة البطيئة، وفوقها خط أزرق للمسار بعد المطابقة بثقة 0.83، وفي الصندوق أن المحرك طابق النقاط الستين ولم يستبعد أي نقطة](https://arabroot.io/content/images/2026/10/traffic-data-05-matched-trace.webp)

النقاط تتقارب عندما تبطئ السيارة، وهذا كل ما يحتاجه النظام ليعرف السرعة

ولاحظ في الصورة شيئاً بسيطاً وهو جوهر الفكرة كلها: النقاط كل خمس ثوان، فعندما تبطئ السيارة تتقارب النقاط، ولا يحتاج النظام أي شيء من السائق غير موقعه ووقته.

### من القطع إلى خريطة الزحام

نحتاج إحداثيات نقاط كل قطعة لنرسمها، فنستخرجها من ملف الرياض بأداة osmium بأرقامها التي كتبها الملف السابق في `node-ids.txt`:

```bash
docker run --rm --name lab-routing-osmium -v lab-routing-data:/osm -v "$PWD/traffic:/w" debian:13.1 bash -c '
  apt-get update -qq >/dev/null && apt-get install -y -qq osmium-tool >/dev/null 2>&1 &&
  osmium getid /osm/riyadh.osm.pbf -i /w/node-ids.txt -f opl -o /w/nodes.opl --overwrite && wc -l /w/node-ids.txt /w/nodes.opl'
```

```text
  27285 /w/node-ids.txt
  27285 /w/nodes.opl
  54570 total
```

ثم الملف الأخير يطبق الحد الأدنى k ويكتب طبقة الخريطة وملف السرعات لمحرك المسارات، ولأننا صنعنا «الحقيقة» بأنفسنا فهو يقيس أيضاً خطأ التقدير:

```javascript
// build.mjs: keep only segments seen by at least K different vehicles, then write
//   web/speeds.geojson  (the heatmap layer)   and   speeds.csv  (from_osm_id,to_osm_id,speed_km_h for OSRM)
import { readFileSync, writeFileSync } from 'node:fs';
const K = 5;
const ZONE = { w: 46.660, s: 24.680, e: 46.700, n: 24.730 };  // the same made-up zone as simulate.mjs
const inZone = ([x, y]) => x > ZONE.w && x < ZONE.e && y > ZONE.s && y < ZONE.n;

const xy = new Map(readFileSync('/w/nodes.opl', 'utf8').trim().split('\n').map(l => {
  const id = l.match(/^n(\d+)/)[1], x = +l.match(/ x([-\d.]+)/)[1], y = +l.match(/ y([-\d.]+)/)[1];
  return [id, [x, y]];
}));
const rows = JSON.parse(readFileSync('/w/segments.json', 'utf8'));
const kept = rows.filter(r => r.vehicles >= K);
console.log(`segments: ${rows.length}, seen by >= ${K} vehicles: ${kept.length}, hidden: ${rows.length - kept.length}`);

const features = [], csv = [], err = { zone: [], other: [] };
for (const r of kept) {
  const [a, b] = r.key.split(',');
  const truth = r.free * (inZone(xy.get(a)) ? 0.35 : 1);   // the speed we gave the synthetic cars
  err[inZone(xy.get(a)) ? 'zone' : 'other'].push(Math.abs(r.speed - truth) / truth);
  features.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: [xy.get(a), xy.get(b)] },
    properties: { speed: Math.round(r.speed), free: Math.round(r.free), ratio: +(r.speed / r.free).toFixed(2), vehicles: r.vehicles } });
  csv.push(`${a},${b},${Math.max(1, Math.round(r.speed))}`);
}
const med = (a) => (a.sort((x, y) => x - y)[Math.floor(a.length / 2)] * 100).toFixed(0);
console.log(`median error, inside the slow zone: ${med(err.zone)}% (${err.zone.length} segments), elsewhere: ${med(err.other)}% (${err.other.length} segments)`);
writeFileSync('/w/web/speeds.geojson', JSON.stringify({ type: 'FeatureCollection', features }));
writeFileSync('/w/speeds.csv', csv.join('\n') + '\n');
console.log('speeds.csv first lines:\n' + csv.slice(0, 3).join('\n'));
```

```bash
docker run --rm --name lab-routing-build -v "$PWD/traffic:/w" -v "$PWD/web:/w/web" node:22-alpine node /w/build.mjs
```

```text
segments: 28511, seen by >= 5 vehicles: 4060, hidden: 24451
median error, inside the slow zone: 5% (259 segments), elsewhere: 3% (3801 segments)
speeds.csv first lines:
14088243820,6082330788,70
6082330788,9551458235,70
9181146701,9181146699,53
```

في المخرج أعلاه لاحظ التالي:

- من 28,511 قطعة مرت عليها سيارة واحدة على الأقل، ظهرت 4,060 فقط، أي قرابة 14%، والباقي مرت عليه أقل من خمس سيارات. و393 سيارة في ساعة واحدة رقم صغير لمدينة، ولكن النسبة تبين لماذا تحتاج خرائط الزحام عدداً هائلاً من المستخدمين.
- الوسيط يقترب من الحقيقة التي صنعناها: خطأ 5% داخل المنطقة البطيئة و3% خارجها، مع أن كل سيارة سارت بسرعة تختلف عشرة بالمئة صعوداً ونزولاً عن سرعة الشارع.
- كل سطر في `speeds.csv` هو رقم نقطة البداية ثم رقم نقطة النهاية ثم السرعة بالكيلومتر في الساعة، وهذه هي الصيغة التي يطلبها OSRM كما في [صفحة Traffic في توثيقه](https://github.com/Project-OSRM/osrm-backend/wiki/Traffic?ref=arabroot.io).

الآن نرسم الطبقة على الخريطة بصفحة `traffic.html` في مجلد `web`، ولون كل قطعة هو نسبة سرعتها المقاسة إلى سرعتها الحرة:

```html
<!doctype html>
<html lang="ar" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>سرعة الطرق من بيانات اصطناعية</title>
<link rel="stylesheet" href="/lib/maplibre-gl.css">
<style>
html, body, #map { margin: 0; height: 100%; font-family: system-ui, sans-serif; }
#info { position: absolute; top: 12px; right: 12px; z-index: 1; background: #fff; padding: 10px 14px; border-radius: 10px; box-shadow: 0 2px 8px #0003; font-size: 15px; line-height: 1.7; }
#info b { display: inline-block; width: 14px; height: 6px; border-radius: 3px; margin-left: 6px; vertical-align: middle; }
#info .warn { color: #B42318; font-weight: 600; }
</style>
<script type="importmap">{ "imports": { "fflate": "/lib/fflate.mjs" } }</script>
</head>
<body>
<div id="info"></div>
<div id="map"></div>
<script type="module">
import * as maplibregl from '/lib/maplibre-gl.mjs';
import { Protocol } from '/lib/pmtiles.mjs';
import { layers, namedFlavor } from '/lib/basemaps.mjs';

maplibregl.addProtocol('pmtiles', new Protocol().tile);
const params = new URLSearchParams(location.search);
const mode = params.get('mode') || 'heat';          // heat | route
const map = window.map = new maplibregl.Map({
  container: 'map', center: mode === 'heat' ? [46.70, 24.72] : [46.674, 24.708], zoom: mode === 'heat' ? 11.3 : 13.2,
  style: { version: 8, glyphs: '/assets/fonts/{fontstack}/{range}.pbf', sprite: `${location.origin}/assets/sprites/v4/light`,
    sources: { protomaps: { type: 'vector', url: 'pmtiles:///tiles/riyadh.pmtiles',
      attribution: '<a href="https://www.openstreetmap.org/copyright">© OpenStreetMap</a>' } },
    layers: layers('protomaps', namedFlavor('light'), { lang: 'ar' }) }
});

map.on('load', async () => {
  // the heatmap: every segment seen by 5 vehicles or more, coloured by observed speed / free-flow speed
  map.addSource('speeds', { type: 'geojson', data: '/speeds.geojson' });
  map.addLayer({ id: 'speeds', type: 'line', source: 'speeds', layout: { 'line-cap': 'round' },
    paint: { 'line-width': mode === 'heat' ? 3 : 4, 'line-opacity': mode === 'heat' ? 0.9 : 0.5,
      'line-color': ['step', ['get', 'ratio'], '#C62828', 0.5, '#F59E0B', 0.75, '#1F8A55'] } });
  // the made-up slow zone, drawn so nobody mistakes it for real traffic
  map.addSource('zone', { type: 'geojson', data: { type: 'Feature', geometry: { type: 'Polygon',
    coordinates: [[[46.66, 24.68], [46.70, 24.68], [46.70, 24.73], [46.66, 24.73], [46.66, 24.68]]] } } });
  map.addLayer({ id: 'zone', type: 'line', source: 'zone', paint: { 'line-color': '#5F6470', 'line-width': 2, 'line-dasharray': [3, 2] } });

  let html = '<div class="warn">بيانات اصطناعية للتجربة فقط</div>' +
    '<div><b style="background:#1F8A55"></b><bdi>75%</bdi> من السرعة الحرة أو أكثر</div>' +
    '<div><b style="background:#F59E0B"></b>من <bdi>50%</bdi> إلى <bdi>75%</bdi></div>' +
    '<div><b style="background:#C62828"></b>أقل من <bdi>50%</bdi></div>';
  if (mode === 'route') {
    const q = '46.6575,24.7406;46.6900,24.6770?overview=full&geometries=geojson';
    const [free, traffic] = await Promise.all([
      fetch(`/osrm/route/v1/driving/${q}`).then(r => r.json()),
      fetch(`/osrm-traffic/route/v1/driving/${q}`).then(r => r.json())]);
    [['free', free, '#1F6FEB', [1, 0]], ['traffic', traffic, '#8250DF', [2, 1.5]]].forEach(([id, j, color, dash]) => {
      map.addSource(id, { type: 'geojson', data: { type: 'Feature', geometry: j.routes[0].geometry } });
      map.addLayer({ id, type: 'line', source: id, layout: { 'line-cap': 'round', 'line-join': 'round' },
        paint: { 'line-color': color, 'line-width': 6, 'line-opacity': 0.9, 'line-dasharray': dash } });
    });
    const t = (j) => `${(j.routes[0].distance / 1000).toFixed(1)} كم، ${(j.routes[0].duration / 60).toFixed(1)} دقيقة`;
    html += `<hr><div><b style="background:#1F6FEB"></b>دون الزحام: ${t(free)}</div><div><b style="background:#8250DF"></b>مع السرعات المقاسة: ${t(traffic)}</div>`;
  }
  document.getElementById('info').innerHTML = html;
  window.__done = true;
});
</script>
</body>
</html>
```

ثم نفتح `http://127.0.0.1:18723/traffic.html`، والصورة التالية هي خريطة الزحام كما ظهرت:

![خريطة وسط الرياض وعليها شبكة من الطرق الملونة: أغلبها أخضر أي 75% من السرعة الحرة أو أكثر، وداخل مستطيل متقطع في حي العليا خطوط حمراء على طريق الملك فهد وطريق العروبة وطريق الأمير محمد بن عبدالعزيز أي أقل من 50%، وفي الزاوية تحذير بالأحمر أن البيانات اصطناعية للتجربة فقط، والشوارع الصغيرة بلا لون لأنها لم تصل إلى خمس مركبات](https://arabroot.io/content/images/2026/10/traffic-data-06-heatmap.webp)

خريطة الزحام من نقاط اصطناعية: النظام اكتشف المنطقة البطيئة التي صنعناها دون أن يعرف حدودها

والنظام لم يعرف حدود المستطيل، وإنما اكتشفه من تقارب النقاط وحده، ولاحظ أيضاً أن الشوارع الداخلية في المستطيل بلا لون، لأن السيارات الاصطناعية سلكت الطرق الكبيرة ولم يمر على الشوارع الصغيرة خمس منها.

### نعيد السرعات إلى محرك المسارات

هنا تظهر فائدة طريقة MLD التي شرحناها في مقال المسارات: لا نعيد التجهيز كله، وإنما نعيد خطوة `osrm-customize` على نسخة من الملفات مع ملف السرعات، ثم نشغل خادماً ثالثاً عليها:

```bash
docker run --rm --name lab-routing-osrm -v lab-routing-data:/data -v "$PWD/traffic:/w:ro" ghcr.io/project-osrm/osrm-backend:v26.10.0-debian bash -c '
  rm -rf /data/mld-traffic && cp -r /data/mld /data/mld-traffic && cd /data/mld-traffic &&
  time osrm-customize riyadh.osrm --segment-speed-file /w/speeds.csv' 2>&1 | grep -E "Used|Speed values|RAM|^real"
docker run -d --name lab-routing-osrm-traffic --network lab-routing-net -v lab-routing-data:/data -p 127.0.0.1:18724:5000 \
  ghcr.io/project-osrm/osrm-backend:v26.10.0-debian osrm-routed --algorithm mld /data/mld-traffic/riyadh.osrm
```

```text
[2026-10-09T00:27:02] [info] Used 935258 speeds from LUA profile or input map
[2026-10-09T00:27:02] [info] Used 4060 speeds from /w/speeds.csv
[2026-10-09T00:27:02] [warn] Speed values were used to update 4060 segments for 'routability' profile
[2026-10-09T00:27:07] [info] RAM: peak bytes used: 149577728
real	0m4.682s
```

أقل من خمس ثوان لتحديث سرعات المدينة، وهذا ما يجعل التحديث كل بضع دقائق ممكناً. والتحذير في السطر الثالث يقول إن الوزن `routability` نفسه تحدث بالسرعات الجديدة، لأن الملف فيه ثلاثة أعمدة فقط، وإذا أردت أن يتغير الزمن ويبقى الوزن فهناك عمود رابع تشرحه صفحة التوثيق. الآن نسأل الخادمين، القديم والجديد، عن طريق يمر في المنطقة البطيئة على طريق الملك فهد:

```bash
for p in 18720 18724; do
  curl -s "http://127.0.0.1:$p/route/v1/driving/46.6575,24.7406;46.6900,24.6770?overview=false&steps=true" \
    | jq -r '.routes[0] | "\(.distance/100|round/10) km, \(.duration/6|round/10) min: \([.legs[0].steps[] | select(.distance > 700) | .name | select(. != "")] | reduce .[] as $n ([]; if .[-1] == $n then . else . + [$n] end) | join(" | "))"'
done
```

```text
10 km, 8.3 min: طريق الملك فهد
9.6 km, 10.9 min: طريق الملك فهد | شارع العليا
```

زمن الوصول أصبح 10.9 دقيقة بدل 8.3، والمحرك غير الطريق نفسه فخرج من طريق الملك فهد إلى شارع العليا الموازي، والصورة التالية من الصفحة نفسها بالرابط `traffic.html?mode=route` بعد أن أضفنا إلى `nginx.conf` القسم التالي وأعدنا تحميله بالأمر `docker exec lab-routing-web nginx -s reload`:

```nginx
    location /osrm-traffic/ {
        proxy_pass http://lab-routing-osrm-traffic:5000/;
    }
```

![خريطة حي العليا مكبرة وعليها المستطيل المتقطع وخطوط الزحام الحمراء، ومساران من الشمال إلى الجنوب: الأزرق دون الزحام على طريق الملك فهد 10.0 كم و8.3 دقيقة، والبنفسجي المتقطع مع السرعات المقاسة 9.6 كم و10.9 دقيقة ينتقل إلى شارع العليا الموازي شرقاً داخل المستطيل](https://arabroot.io/content/images/2026/10/traffic-data-07-reroute.webp)

بعد إضافة السرعات المقاسة ارتفع زمن الوصول وانتقل المسار إلى شارع مواز

وقد يبدو هذا نجاحاً كاملاً، ولكن لاحظ أين ذهب المسار: إلى شارع العليا داخل المنطقة البطيئة نفسها. وعندما طلبنا من الخادم الجديد `annotations=datasources` لهذا المسار وجدنا أن 2.1 كيلومتر فقط منه بسرعات من ملفنا، و7.5 كيلومتر بالسرعات الافتراضية، والسبب أن شارع العليا لم تمر عليه خمس سيارات اصطناعية، فبقيت سرعته «حرة» في المحرك، مع أنه في الحقيقة التي صنعناها بطيء مثل طريق الملك فهد. وهذه أهم نتيجة في المختبر: **المحرك يهرب من الشوارع التي تعرف زحامها إلى الشوارع التي لا تعرف عنها شيئاً**، لذلك لا تكفي سرعات القطع المقاسة، وتحتاج تقديراً للقطع التي لا بيانات فيها، من التاريخ أو من القطع المجاورة، أو على الأقل ألا تعامل غياب البيانات كأنه طريق فارغ.

وصفحة `trace.html` التي رسمنا بها الرحلة الواحدة تقرأ ملفاً صغيراً `trace-sample.json` فيه 60 نقطة من رحلة واحدة، وترسل الطلب نفسه إلى `/osrm/match`:

```html
<!doctype html>
<html lang="ar" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>مطابقة نقاط GPS مع الطريق</title>
<link rel="stylesheet" href="/lib/maplibre-gl.css">
<style>
html, body, #map { margin: 0; height: 100%; font-family: system-ui, sans-serif; }
#info { position: absolute; top: 12px; right: 12px; z-index: 1; background: #fff; padding: 10px 14px; border-radius: 10px; box-shadow: 0 2px 8px #0003; font-size: 15px; line-height: 1.7; }
#info b { display: inline-block; width: 14px; height: 6px; border-radius: 3px; margin-left: 6px; vertical-align: middle; }
#info i { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-left: 8px; vertical-align: middle; background: #C62828; }
#info .warn { color: #B42318; font-weight: 600; }
</style>
<script type="importmap">{ "imports": { "fflate": "/lib/fflate.mjs" } }</script>
</head>
<body>
<div id="info"></div>
<div id="map"></div>
<script type="module">
import * as maplibregl from '/lib/maplibre-gl.mjs';
import { Protocol } from '/lib/pmtiles.mjs';
import { layers, namedFlavor } from '/lib/basemaps.mjs';

maplibregl.addProtocol('pmtiles', new Protocol().tile);
const map = window.map = new maplibregl.Map({
  container: 'map', center: [46.68, 24.70], zoom: 14,
  style: { version: 8, glyphs: '/assets/fonts/{fontstack}/{range}.pbf', sprite: `${location.origin}/assets/sprites/v4/light`,
    sources: { protomaps: { type: 'vector', url: 'pmtiles:///tiles/riyadh.pmtiles',
      attribution: '<a href="https://www.openstreetmap.org/copyright">© OpenStreetMap</a>' } },
    layers: layers('protomaps', namedFlavor('light'), { lang: 'ar' }) }
});

map.on('load', async () => {
  const { vehicle, fixes } = await fetch('/trace-sample.json').then(r => r.json());
  const q = fixes.map(f => `${f[0]},${f[1]}`).join(';') + '?timestamps=' + fixes.map(f => f[2]).join(';') +
    '&radiuses=' + fixes.map(() => 10).join(';') + '&overview=full&geometries=geojson';
  const j = await fetch(`/osrm/match/v1/driving/${q}`).then(r => r.json());
  map.addSource('matched', { type: 'geojson', data: { type: 'FeatureCollection',
    features: j.matchings.map(m => ({ type: 'Feature', geometry: m.geometry })) } });
  map.addLayer({ id: 'matched', type: 'line', source: 'matched', layout: { 'line-cap': 'round', 'line-join': 'round' },
    paint: { 'line-color': '#1F6FEB', 'line-width': 6, 'line-opacity': 0.75 } });
  map.addSource('raw', { type: 'geojson', data: { type: 'FeatureCollection',
    features: fixes.map(f => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [f[0], f[1]] } })) } });
  map.addLayer({ id: 'raw', type: 'circle', source: 'raw',
    paint: { 'circle-radius': 4.5, 'circle-color': '#C62828', 'circle-stroke-color': '#fff', 'circle-stroke-width': 1.2 } });
  const b = new maplibregl.LngLatBounds(); fixes.forEach(f => b.extend([f[0], f[1]]));
  map.fitBounds(b, { padding: 60, duration: 0 });
  const kept = j.tracepoints.filter(Boolean).length;
  document.getElementById('info').innerHTML = `<div class="warn">مركبة اصطناعية <bdi>${vehicle}</bdi></div>` +
    `<div><i></i>${fixes.length} نقطة GPS، كل 5 ثوان</div>` +
    `<div><b style="background:#1F6FEB"></b>المسار بعد المطابقة، الثقة <bdi>${j.matchings[0].confidence.toFixed(2)}</bdi></div>` +
    `<div>طابق ${kept} نقطة واستبعد ${fixes.length - kept}</div>`;
  window.__done = true;
});
</script>
</body>
</html>
```

## نكسر الإعداد: سطر فارغ في ملف السرعات

توثيق OSRM ينبه إلى أن كل سطر في ملف السرعات يجب أن يحتوي بيانات صحيحة، فأنشأنا ملفاً فيه ثلاثة أسطر من `speeds.csv` بينها سطر فارغ:

```bash
(head -2 traffic/speeds.csv; echo; sed -n 3p traffic/speeds.csv) > traffic/bad.csv
docker run --rm --name lab-routing-osrm -v lab-routing-data:/data -v "$PWD/traffic:/w:ro" ghcr.io/project-osrm/osrm-backend:v26.10.0-debian bash -c '
  rm -rf /tmp/t && cp -r /data/mld /tmp/t && cd /tmp/t && osrm-customize riyadh.osrm --segment-speed-file /w/bad.csv; echo exit=$?' 2>&1 | grep -v '^\[.*info' | tail -8
```

```text
[2026-10-09T00:32:08] [error] CSV file /w/bad.csv malformed on line 4:
 9181146701,9181146699,53
include/updater/csv_file_parser.hpp:136include/updater/csv_file_parser.hpp:101
exit=1
```

والخطوة فشلت كلها، ولم يتحدث أي شارع. ولاحظ أن الرسالة تذكر السطر الرابع وتعرض محتواه، والسطر الفارغ هو الثالث، أي أن الرسالة تشير إلى السطر الذي بعد المشكلة. وفي نظام يكتب هذا الملف آلياً كل خمس دقائق، فإن سطراً فارغاً في آخر الملف من برنامج يكتب `\n` مرتين يعني أن السرعات تتوقف عن التحديث بصمت إذا لم تراقب رمز الخروج، لذلك افحص الملف قبل التطبيق وراقب `exit` في كل دورة.

## إذا أردت أن تبني هذا لأسطولك

لا تحتاج مليار كيلومتر يومياً لتستفيد من هذه الفكرة، فشركة توصيل فيها خمسمئة مندوب تملك بيانات مسبار عن الشوارع التي تهمها هي بالضبط، وفي الأوقات التي تعمل فيها. والطريق العملي:

- **الاستقبال:** تطبيق المندوب يرسل النقاط على دفعات، والسيرفر يضعها في طابور أو بث أحداث Event Stream قبل المعالجة، كما شرحنا في [الطابور Queue أم بث الأحداث Event Streaming؟](https://arabroot.io/articles/%D8%A7%D9%84%D8%B7%D8%A7%D8%A8%D9%88%D8%B1-%D8%A7%D9%85-%D8%A8%D8%AB-%D8%A7%D9%84%D8%A7%D8%AD%D8%AF%D8%A7%D8%AB-message-queue-%D9%88-event-streaming/)، حتى لا يضيع شيء عندما تتأخر المطابقة.
- **التخزين:** النقاط الخام تحتاج مدة حفظ قصيرة ومعلنة، والسرعات المجمعة جدول صغير لكل قطعة وربع ساعة، ويكفيه PostgreSQL مع [PostGIS](https://arabroot.io/articles/postgresql-%D9%84%D9%83%D9%84-%D8%B4%D9%8A%D8%A1-%D9%85%D8%AA%D9%89-%D8%AA%D9%83%D9%81%D9%8A%D9%83-%D9%82%D8%A7%D8%B9%D8%AF%D8%A9-%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA-%D9%88%D8%A7%D8%AD%D8%AF%D8%A9/#postgis) أو جدول سلاسل زمنية.
- **الدورة:** كل خمس دقائق تطابق النقاط الجديدة وتحسب السرعات وتكتب الملف وتعيد `osrm-customize` على نسخة، ثم تبدل الخادم، ولا تنس القطع التي لا بيانات فيها.
- **الخصوصية:** مواقع المندوبين بيانات شخصية حتى لو كانت أثناء العمل، فاكتب سياسة واضحة لما تجمعه ومتى تحذفه، ولا تعرض مسار مندوب بعينه لمن لا يحتاجه.

## تنظيف المختبر

إذا انتهيت من مقال المسارات أيضاً فاحذف كل شيء بالأوامر في [قسم التنظيف هناك](https://arabroot.io/articles/%D8%AD%D8%B3%D8%A7%D8%A8-%D8%A7%D9%82%D8%B5%D8%B1-%D8%B7%D8%B1%D9%8A%D9%82-%D9%88%D8%B2%D9%85%D9%86-%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-%D9%85%D8%B9-osrm-%D9%88-valhalla/#cleanup)، وأضف إليها الخادم الثالث وملفات هذا المقال، ومنها الرحلات الاصطناعية:

```bash
docker rm -f lab-routing-osrm-traffic
rm -f traffic/traces.json traffic/segments.json traffic/node-ids.txt traffic/nodes.opl traffic/speeds.csv traffic/bad.csv web/speeds.geojson web/trace-sample.json
```

## تدريب عملي

- غير `K` في `build.mjs` إلى 2 ثم إلى 10، وقارن عدد القطع الظاهرة وخطأ التقدير، ثم فكر: أي القيمتين تقبل أن تنشرها لو كانت البيانات حقيقية؟
- غير `NOISE_M` في `simulate.mjs` إلى 30 متراً، وأعد المطابقة، وراقب عدد النقاط المستبعدة ووسيط الثقة.

## الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

- الخرائط تعرف الزحام من بيانات المسبار: نقاط GPS من الهواتف والأساطيل، تطابق مع الطرق، وتصبح وسيط سرعة لكل قطعة في نافذة زمنية.
- أقرب شارع لكل نقطة يخطئ عند الطرق المتوازية، ونموذج ماركوف المخفي من Newson وKrumm يختار السلسلة الأكثر احتمالاً، وهو ما تفعله خدمة `match` في OSRM.
- زمن الوصول يجمع التاريخ والقراءات اللحظية والتوقع، وGoogle تنشر أنها تستخدم شبكات عصبية للرسوم البيانية لذلك منذ 2020، وWaze يضيف بلاغات السائقين مع درجة ثقة لكل بلاغ.
- الرحلة الخام بيانات شخصية، والتجميع لا يكفي دون حد أدنى للعدد كما أظهرت خريطة Strava في 2018، وعدد الهواتف وحده يمكن خداعه كما أظهر Simon Weckert في 2020.
- في المختبر الاصطناعي اكتشف النظام المنطقة البطيئة بخطأ 5%، وحدث OSRM سرعاته في أقل من خمس ثوان، وارتفع زمن الوصول من 8.3 إلى 10.9 دقيقة، ثم هرب المسار إلى شارع بلا بيانات.

[![](https://arabroot.io/content/images/2026/10/routing-cover-cover.webp)دليلحساب أقصر طريق وزمن الوصول مع OSRM وValhallaكيف يحسب محرك المسارات أقصر طريق وزمن الوصول على شوارع مدينتك، وكيف تشغله على سيرفرك وتقيس سرعته بنفسك.](https://arabroot.io/articles/%D8%AD%D8%B3%D8%A7%D8%A8-%D8%A7%D9%82%D8%B5%D8%B1-%D8%B7%D8%B1%D9%8A%D9%82-%D9%88%D8%B2%D9%85%D9%86-%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-%D9%85%D8%B9-osrm-%D9%88-valhalla/)[![](https://arabroot.io/content/images/2026/10/build-map-app-cover-cover.webp)دليلكيف تبني تطبيق خرائط وملاحة مثل هدهد وGoogle Maps؟ما الذي يقف خلف كل ميزة في تطبيقات الخرائط، وكيف تبني نسخة أولى منها بأدوات مفتوحة المصدر وبيانات مرخصة بشكل صحيح.](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%A8%D9%86%D9%8A-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-%D8%AE%D8%B1%D8%A7%D8%A6%D8%B7-%D9%88%D9%85%D9%84%D8%A7%D8%AD%D8%A9-%D9%85%D8%AB%D9%84-%D9%87%D8%AF%D9%87%D8%AF-%D9%88-google-maps/)[![](https://arabroot.io/content/images/2026/10/self-hosted-maps-cover-cover.webp)دليلخرائطك على سيرفرك مع OpenStreetMap وMapLibreكيف تعرض خريطة تفاعلية لمدينتك من سيرفرك ببيانات OpenStreetMap، بالعربية والإنجليزية ودون مفتاح API أو مزود خارجي.](https://arabroot.io/articles/%D8%AE%D8%B1%D8%A7%D8%A6%D8%B7%D9%83-%D8%B9%D9%84%D9%89-%D8%B3%D9%8A%D8%B1%D9%81%D8%B1%D9%83-%D9%85%D8%B9-openstreetmap-%D9%88-maplibre/)[![](https://arabroot.io/content/images/2026/10/queue-vs-streaming-cover-cover.webp)دليلالطابور Queue أم بث الأحداث Event Streaming؟ما يحدث للرسالة بعد قراءتها هو الفرق بين طابور مثل RabbitMQ وسجل أحداث مثل Kafka، ومنه تعرف أيهما يناسب مشروعك.](https://arabroot.io/articles/%D8%A7%D9%84%D8%B7%D8%A7%D8%A8%D9%88%D8%B1-%D8%A7%D9%85-%D8%A8%D8%AB-%D8%A7%D9%84%D8%A7%D8%AD%D8%AF%D8%A7%D8%AB-message-queue-%D9%88-event-streaming/)[![](https://arabroot.io/content/images/2026/10/postgres-for-everything-cover-cover.webp)دليلPostgreSQL لكل شيء: متى يكفيك ومتى تحتاج غيره؟كم عملاً يستطيع PostgreSQL واحد أن يقوم به في شركتك الناشئة قبل أن تحتاج Redis أو Elasticsearch، وما الإشارة التي تقول لك إن الوقت قد حان.](https://arabroot.io/articles/postgresql-%D9%84%D9%83%D9%84-%D8%B4%D9%8A%D8%A1-%D9%85%D8%AA%D9%89-%D8%AA%D9%83%D9%81%D9%8A%D9%83-%D9%82%D8%A7%D8%B9%D8%AF%D8%A9-%D8%A8%D9%8A%D8%A7%D9%86%D8%A7%D8%AA-%D9%88%D8%A7%D8%AD%D8%AF%D8%A9/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال، وتنفيذ المختبر ببيانات اصطناعية على OSRM v26.10.0 وبيانات Geofabrik لدول الخليج حتى 2026-10-08 وosmium 1.18.0 وNode.js 22 وDocker Engine 29.8.0 على Windows 11 مع Docker Desktop.