الأدلة التقنية

حساب أقصر طريق وزمن الوصول مع OSRM وValhalla

كيف يحسب محرك المسارات أقصر طريق وزمن الوصول على شوارع مدينتك، وكيف تشغله على سيرفرك وتقيس سرعته بنفسك.

حساب أقصر طريق وزمن الوصول مع OSRM وValhalla

في مقال البحث عن العناوين Geocoding مع Nominatim حولنا اسم المكان إلى نقطة على الخريطة، والخطوة التالية التي يطلبها كل تطبيق توصيل أو مندوبين أو حجز مواعيد هي: كم يبعد هذا المكان عن ذاك بالسيارة، وكم دقيقة يحتاج السائق، وأي الطرق يسلك؟ والطريقة البسيطة هي أن تحسب المسافة المستقيمة بين النقطتين وتضربها في رقم تقديري، ولكن بعد أول أسبوع سوف تكتشف أن المسافة المستقيمة لا تعرف الطرق ذات الاتجاه الواحد ولا الجسور ولا أن الطريق السريع يلتف حول الحي، فيخرج زمن الوصول أقل من الحقيقة بكثير مرة وأكثر منها مرة. والحل الثاني هو أن ترسل كل سؤال إلى خدمة مسارات تجارية، ولكنك تدفع على كل طلب وترسل مواقع عملائك ومندوبيك إلى شركة أخرى، وهي المشكلة نفسها التي ناقشناها مع الخريطة.

والحل الذي نشرحه هنا هو محرك مسارات Routing Engine على سيرفرك يعمل على بيانات OpenStreetMap نفسها التي عرضنا بها الخريطة، وهذا هو المقال الثالث في مرحلة الخدمات المتخصصة: الخرائط والبث المباشر ومعالجة الوسائط، وسوف نناقش فيه ما يلي:

  • كيف تتحول الشوارع إلى رسم بياني Graph، ومن أين يأتي وزن كل شارع.
  • من أين تأتي السرعات: الوسم maxspeed في OpenStreetMap والقيم الافتراضية في ملف ال Profile، مع القيم الحقيقية من OSRM.
  • خوارزميات أقصر مسار: Dijkstra وA* والبحث من الطرفين، ثم Contraction Hierarchies وMulti-Level Dijkstra التي تجعل الجواب في أجزاء من الثانية.
  • مختبر كامل على Docker لمدينة الرياض: مسار حقيقي بالمسافة والزمن مع البدائل، ومصفوفة المسافات، ومناطق الوصول خلال 10 و20 دقيقة على الخريطة، مع قياس وقت التجهيز والذاكرة وزمن الاستجابة.
  • ما لا يعرفه المحرك: الأزمنة هنا لطرق فارغة دون زحام، والسبب، وأين نكمل ذلك.

من الشارع إلى الرسم البياني Graph

بيانات OpenStreetMap تصف الشوارع بنوعين من العناصر: النقطة Node وهي إحداثية واحدة، والطريق Way وهو قائمة نقاط متتالية معها وسوم Tags تصف نوعه مثل highway=primary واتجاهه مثل oneway=yes وسرعته المسموحة مثل maxspeed=60. ومحرك المسارات لا يبحث في هذه البيانات كما هي، وإنما يحولها مرة واحدة إلى رسم بياني موجه Directed Graph: التقاطعات عقد Nodes، وكل قطعة شارع بين تقاطعين رابط Edge له اتجاه، والشارع ذو الاتجاهين يصبح رابطين، والشارع ذو الاتجاه الواحد يصبح رابطاً واحداً فقط، فلا يوجد أصلاً طريق للعودة فيه. وقد شرحنا الرسم البياني وطرق حفظه في الذاكرة في قسم الرسم البياني Graph في مقال هياكل البيانات، والصورة التالية تبين التحويل على أربعة شوارع:

مخطط من ثلاثة أجزاء: على اليسار أربعة شوارع من OpenStreetMap، طريق رئيسي primary بسرعة 65 كم في الساعة وجزء منه عليه الوسم maxspeed=60، وشارعان سكنيان residential بسرعة 25، وشارع ذو اتجاه واحد oneway=yes. وعلى اليمين الرسم البياني الموجه بخمس عقد A وB وC وD وE، وعلى كل رابط وزنه بالثواني: 22 ثانية من A إلى B، و25 ثانية من B إلى E لأن السرعة 48 من maxspeed، و43 ثانية لكل شارع سكني، و58 ثانية من D إلى C فقط ولا يوجد رابط للعودة. وفي الأسفل القاعدة: الوزن يساوي الطول مقسوماً على السرعة، والسرعة تساوي maxspeed ضرب 0.8 إن وجد وإلا القيمة الافتراضية لنوع الطريق، والانعطاف يضيف حتى 7.5 ثانية وحتى ثانيتين إضافيتين عند قطع الاتجاه المقابل و20 ثانية للالتفاف
كل قطعة شارع تصبح رابطاً له اتجاه ووزن بالثواني، والشارع ذو الاتجاه الواحد رابط واحد فقط

والوزن Weight هو ما يحاول المحرك أن يجعل مجموعه أقل ما يمكن، فإذا كان الوزن هو الطول بالأمتار حصلت على أقصر طريق Shortest Path، وإذا كان الوزن هو الزمن بالثواني حصلت على أسرع طريق Fastest Path، والفرق بينهما كبير في المدن: فأقصر طريق إلى المطار قد يمر في عشرات الشوارع السكنية والإشارات، وأسرعها يأخذك إلى الطريق الدائري ولو كان أطول بخمسة كيلومترات. لذلك تستخدم كل تطبيقات الملاحة الزمن، أو وزناً قريباً منه كما سوف نرى، ونقول «أقصر طريق» في الكلام العام ونقصد الأقل تكلفة.

وهناك تفصيل مهم وهو أن الانعطاف نفسه له ثمن، فالانعطاف إلى اليسار عند إشارة يأخذ وقتاً أكثر من الاستمرار مستقيماً، وبعض الانعطافات ممنوعة تماماً بعلاقة منع الانعطاف Turn Restriction مثل no_left_turn. ولكي يحسب المحرك ثمن الانعطاف لا يكفي أن تكون العقد هي التقاطعات، لذلك يبني OSRM ما يسميه في توثيق تمثيل الرسم البياني الرسم المبني على الروابط Edge-Based Graph: كل قطعة شارع تصبح عقدة، وكل انتقال مسموح من قطعة إلى أخرى يصبح رابطاً عليه ثمن الانعطاف، فالانعطاف الممنوع ببساطة لا يوجد له رابط.

من أين تأتي السرعات؟

الوزن بالثواني يحتاج سرعة لكل شارع، ومصدرها الأول هو الوسم maxspeed الذي يضعه المتطوعون في OpenStreetMap، والمصدر الثاني عندما لا يوجد هذا الوسم هو ملف وصف المركبة Profile في المحرك، وهو في OSRM ملف Lua اسمه car.lua للسيارة، وفيه السرعة الافتراضية لكل نوع طريق. والمقطع التالي منسوخ كما هو من الملف داخل Docker Image التي نستخدمها في المختبر:

    speeds = Sequence {
      highway = {
        motorway        = 90,
        motorway_link   = 45,
        trunk           = 85,
        trunk_link      = 40,
        primary         = 65,
        primary_link    = 30,
        secondary       = 55,
        secondary_link  = 25,
        tertiary        = 40,
        tertiary_link   = 20,
        unclassified    = 25,
        residential     = 25,
        living_street   = 10,
        service         = 15,

وفي الملف نفسه قيم الانعطاف:

      u_turn_penalty                 = 20,
    turn_penalty              = 7.5,
    speed_reduction           = 0.8,
    turn_bias                 = 1.075,
    oncoming_turn_penalty     = 2.0,

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

  • السرعات بالكيلومتر في الساعة، والطريق السريع motorway سرعته الافتراضية 90 مع أن السرعة المسموحة في أغلب الطرق السريعة أعلى من ذلك، والسبب أن هذه القيم تقدير لما يحدث فعلاً على الطريق وليست حد السرعة.
  • إذا كان على الطريق وسم maxspeed فإن الدالة WayHandlers.maxspeed في way_handlers.lua تضربه في speed_reduction أي 0.8، فالطريق الذي حده 60 يحسب عليه المحرك 48 كم في الساعة، وهذا اعتراف صريح بأن السائق لا يسير طوال الوقت بالحد الأقصى.
  • turn_penalty هو أقصى ما يضيفه الانعطاف الحاد من ثوان، ويحسب الملف قيمة أقل للانعطاف الخفيف بحسب زاويته، ويضيف oncoming_turn_penalty حتى ثانيتين للانعطاف الذي يقطع الاتجاه المقابل، وu_turn_penalty عشرين ثانية للالتفاف U-turn.
  • الوزن الافتراضي في الملف اسمه routability وليس الزمن الصافي، وهو الزمن مع تعديلات تفضل الطرق الواضحة، مثل تقليل تفضيل طرق الخدمة service ومواقف السيارات، لذلك سوف ترى في المخرج قيمتين: duration وweight.

وقد يتساءل البعض: إذا كان المتطوعون يضعون maxspeed فلماذا نحتاج القيم الافتراضية أصلاً؟ والإجابة في بيانات الرياض نفسها التي استخدمناها في المختبر، فقد عددنا بأداة osmium الطرق التي تصلح للسيارة فوجدناها 102,907 طرق، منها 11,484 فقط عليها maxspeed، أي قرابة 11%، وفي الطرق الكبيرة motorway وtrunk وprimary 1,143 من 2,447، أي قرابة النصف. وهذا يعني أن أغلب الشوارع الداخلية تحسب بالقيمة الافتراضية لنوعها، فإذا كانت دقة زمن الوصول مهمة لعملك فأول ما تفعله هو إكمال هذه الوسوم في OpenStreetMap لمدينتك، أو تعديل ملف ال Profile بما تعرفه عنها. وفي سجل المختبر أيضاً أن المحرك قرأ 91 علاقة منع انعطاف فقط لمدينة بحجم الرياض، أي أن أغلب لوحات «ممنوع الانعطاف» غير موجودة في البيانات، والمحرك سوف يقترح انعطافات ممنوعة في الواقع حتى يضيفها أحد.

كيف يجد المحرك أقصر طريق؟

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

خمس لوحات: Dijkstra دائرة كبيرة حول نقطة البداية S حتى تصل إلى الهدف T، وA* شكل بيضاوي أصغر يتجه نحو T، والبحث من الطرفين Bidirectional دائرتان أصغر من S ومن T تلتقيان في الوسط، وContraction Hierarchies بحثان يصعدان من العقد العادية إلى العقد الأهم ويلتقيان في الأعلى مع روابط مختصرة shortcut، وMulti-Level Dijkstra شبكة مقسمة إلى خلايا تحسب تكلفة عبور كل خلية مسبقاً فيبحث داخل خليتي S وT ويقفز فوق الباقي
كلما قلت المساحة التي يزورها البحث كان الجواب أسرع، وآخر طريقتين تدفعان ثمن ذلك بتجهيز مسبق
  • Dijkstra: نشرها Edsger Dijkstra سنة 1959، وتبدأ من نقطة البداية وتوسع دائرة من العقد الأقرب فالأقرب حتى تصل إلى الهدف، وتحتاج طابور أولوية Priority Queue كما شرحنا في قسم الكومة Heap في مقال هياكل البيانات، وتعقيدها مع الكومة O((V + E) log V) بلغة Big-O. وهي صحيحة دائماً، ولكن لمسار طوله 40 كيلومتراً في مدينة كبيرة سوف تزور كل الشوارع داخل دائرة نصف قطرها 40 كيلومتراً، أي أغلب المدينة.
  • A*: نشرها Hart وNilsson وRaphael سنة 1968، وهي Dijkstra مع تقدير للمسافة الباقية إلى الهدف، مثل المسافة المستقيمة مقسومة على أعلى سرعة، فتفضل العقد التي في اتجاه الهدف، وتبقى صحيحة ما دام التقدير لا يزيد عن الحقيقة.
  • البحث من الطرفين Bidirectional Search: بحث من البداية وبحث عكسي من الهدف في الوقت نفسه، ويتوقف عندما يلتقيان، ودائرتان بنصف القطر تغطيان مساحة أقل من دائرة واحدة كبيرة. وهذا ما يستخدمه Valhalla للمسارات العادية مع A* من الطرفين Bidirectional A*.
  • Contraction Hierarchies واختصاراً CH: من ورقة Geisberger وزملائه سنة 2008، وفكرتها أن ترتب العقد بحسب أهميتها في مرحلة تجهيز مسبق Preprocessing، ثم «تحذف» العقد الأقل أهمية واحدة بعد الأخرى وتضيف مكانها روابط مختصرة Shortcuts تحفظ الطريق الأقصر الذي كان يمر بها. وعند السؤال يصعد البحثان من الطرفين إلى العقد الأهم فقط ولا ينزلان، فيزوران جزءاً صغيراً جداً من الشبكة، والثمن أن أي تغيير في الأوزان يحتاج تجهيزاً كاملاً من جديد.
  • Multi-Level Dijkstra واختصاراً MLD: هي طريقة OSRM المبنية على فكرة Customizable Route Planning من Microsoft Research سنة 2011، وفيها تقسم الشبكة إلى خلايا Cells بعدة مستويات، وتحسب لكل خلية تكلفة العبور بين نقاط حدودها، فيبحث المحرك بالتفصيل داخل خلية البداية وخلية الهدف فقط ويقفز فوق الخلايا بينهما. والمهم هنا أن التقسيم لا يتغير، وأن حساب التكاليف Customization منفصل وسريع، فإذا تغيرت السرعات بسبب الزحام أعدت هذه المرحلة وحدها في ثوان، وهذا ما سوف نستخدمه في مقال الزحام.

وملف OSRM الرئيسي يوصي بـ MLD كخيار افتراضي، ويستثني حالات خاصة مثل مصفوفات المسافات الكبيرة جداً التي يبقى CH فيها أنسب، وسوف نقيس الفرق بأنفسنا بعد قليل.

ثلاثة محركات مفتوحة المصدر

هناك ثلاثة محركات مسارات مفتوحة المصدر مشهورة تعمل على OpenStreetMap، ونستخدم في المختبر أول اثنين منها:

المحركالرخصةنقاط القوةنقاط الضعف
OSRMBSD-2-Clauseأسرع جواب للمسار ومصفوفة المسافات، ومطابقة نقاط GPS مع الطريق، وتحديث السرعات بملف CSVكل نوع مركبة يحتاج تجهيزاً منفصلاً وخادماً منفصلاً، ولا يحسب مناطق الوصول Isochrones، والتعليمات النصية خارج الخادم
ValhallaMITتكلفة تحسب عند كل طلب، فتغير تفضيلات المركبة من الطلب نفسه، ويحسب مناطق الوصول ويعيد تعليمات نصية بعدة لغاتالمسار الواحد أبطأ من OSRM بكثير كما سوف نقيس
GraphHopperApache-2.0مكتوب بلغة Java، ويجمع CH مع وضع مرن تغير فيه قواعد التكلفة من الطلبلم نختبره في هذا المقال

وقد يتساءل البعض: لماذا لا أستخدم سيرفر OSRM العام وأرتاح من كل هذا؟ والإجابة في سياسة السيرفر العام نفسها: طلب واحد في الثانية كحد أقصى، واستخدام معقول وغير تجاري، ولا ضمان للتوفر أو التحديث، فهو للتجربة وليس لتطبيق فيه عملاء.

التجربة العملية: المسارات في الرياض

سوف نستخدم مستطيل الرياض نفسه من مقالي الخريطة والبحث، ونجهز OSRM مرتين بطريقتي CH وMLD لنقارن بينهما، ثم Valhalla لمناطق الوصول، ونعرض النتائج على خريطة المقال الأول. وكل البيانات في Volume اسمه lab-routing-data بدل مجلد على القرص، وكل المنافذ على 127.0.0.1 فقط.

تجهيز البيانات

شرحنا قص المنطقة بأداة osmium وسبب الخيارات في قسم تجهيز البيانات في مقال Nominatim، وهنا ننفذ الخطوات نفسها داخل ال Volume ونحذف ملف الخليج بعد القص:

docker volume create lab-routing-data
docker run --rm --name lab-routing-osmium -v lab-routing-data:/osm debian:13.1 bash -c '
  apt-get update -qq >/dev/null && apt-get install -y -qq osmium-tool curl ca-certificates >/dev/null 2>&1 && cd /osm &&
  curl -fsSLO https://download.geofabrik.de/asia/gcc-states-latest.osm.pbf &&
  curl -fsSLO https://download.geofabrik.de/asia/gcc-states-latest.osm.pbf.md5 &&
  md5sum -c gcc-states-latest.osm.pbf.md5 && ls -l gcc-states-latest.osm.pbf &&
  osmium fileinfo gcc-states-latest.osm.pbf | grep -i timestamp; osmium --version | head -1;
  time osmium extract --bbox 46.45,24.45,47.05,25.00 --strategy smart -S types=multipolygon,boundary \
    gcc-states-latest.osm.pbf -o riyadh.osm.pbf --overwrite &&
  osmium fileinfo -e riyadh.osm.pbf | grep -E "Number of (nodes|ways|relations)"; ls -l riyadh.osm.pbf;
  rm gcc-states-latest.osm.pbf*'

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

gcc-states-latest.osm.pbf: OK
-rw-r--r-- 1 root root 254801278 Oct  8 23:19 gcc-states-latest.osm.pbf
    osmosis_replication_timestamp=2026-10-08T20:21:06Z
    timestamp=2026-10-08T20:21:06Z
osmium version 1.18.0

real	0m14.213s
user	0m33.874s
sys	0m12.753s
  Number of nodes: 1000733
  Number of ways: 217160
  Number of relations: 681
-rw-r--r-- 1 root root 9556699 Oct  8 23:20 riyadh.osm.pbf

تجهيز OSRM بطريقتين

نستخدم Docker Image الرسمية من الإصدار v26.10.0، وهو إصدار أكتوبر 2026، والمشروع ينشر إصداراً كل شهر برقم السنة والشهر. ولاحظ أن الوسم المتاح للمعالجات العادية هو v26.10.0-debian، أما v26.10.0 وحده فلم يكن موجوداً في السجل عند كتابة المقال، وطلبه يعيد الخطأ not found. والتجهيز بطريقة MLD ثلاث خطوات:

docker run --rm --name lab-routing-osrm -v lab-routing-data:/data ghcr.io/project-osrm/osrm-backend:v26.10.0-debian bash -c '
  mkdir -p /data/mld && cp /data/riyadh.osm.pbf /data/mld/ && cd /data/mld &&
  time osrm-extract -p /opt/car.lua riyadh.osm.pbf &&
  time osrm-partition riyadh.osrm &&
  time osrm-customize riyadh.osrm' 2>&1 | grep -E "Raw input|RAM: peak|^real"

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

[2026-10-09T01:00:33] [info] Raw input contains 1000733 nodes, 217160 ways, and 43 relations, 91 restrictions
[2026-10-09T01:01:03] [info] RAM: peak bytes used: 553000960
real	0m45.049s
[2026-10-09T01:01:19] [info] RAM: peak bytes used: 219508736
real	0m16.319s
[2026-10-09T01:01:25] [info] RAM: peak bytes used: 147296256
real	0m6.230s

ثم بطريقة CH في مجلد آخر، والفرق أن osrm-contract يحل محل الخطوتين الأخيرتين:

docker run --rm --name lab-routing-osrm -v lab-routing-data:/data ghcr.io/project-osrm/osrm-backend:v26.10.0-debian bash -c '
  mkdir -p /data/ch && cp /data/riyadh.osm.pbf /data/ch/ && cd /data/ch &&
  time osrm-extract -p /opt/car.lua riyadh.osm.pbf &&
  time osrm-contract riyadh.osrm' 2>&1 | grep -E "RAM: peak|^real"
[2026-10-09T01:02:36] [info] RAM: peak bytes used: 556601344
real	1m0.687s
[2026-10-09T01:06:08] [info] RAM: peak bytes used: 300929024
real	3m32.318s

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

  • مرحلة osrm-extract واحدة في الطريقتين: تقرأ الملف وتنفذ car.lua على كل طريق وتبني الرسم المبني على الروابط، وتحتاج قرابة 550 ميغابايت من الذاكرة لمدينة واحدة.
  • MLD أنهت باقي التجهيز في 22.5 ثانية (16.3 للتقسيم و6.2 لحساب التكاليف)، أما osrm-contract فاحتاجت ثلاث دقائق ونصفاً، أي قرابة عشرة أضعاف، وهذا الفرق يكبر مع حجم المنطقة.
  • سطر Raw input هو الذي ذكرنا منه رقم 91 لمنع الانعطاف، فالملف فيه مليون نقطة تقريباً، ولكن ما يدخل الرسم البياني منها هو نقاط طرق السيارات فقط.
  • الأزمنة تتغير كثيراً بحسب انشغال الجهاز: في تشغيل أول والجهاز أقل انشغالاً أخذت الخطوات نفسها 24.5 ثانية للاستخراج، و7.5 و2.2 ثانية لخطوتي MLD، و112 ثانية لخطوة CH، والذاكرة كانت متقاربة جداً في التشغيلين، فاعتبر الأزمنة ترتيباً للحجم وقارن النسبة بين الطريقتين، وهي قرابة عشرة أضعاف في التشغيلين.

أول مسار: من قلعة المصمك إلى المطار

الآن نشغل خادمين، واحد لكل طريقة، ولاحظ أن الخيار --algorithm قيمته الافتراضية CH في osrm-routed، فإذا جهزت بطريقة MLD ونسيته فلن يعمل الخادم، والشبكة lab-routing-net سوف نحتاجها للقياس والخريطة:

docker network create lab-routing-net
docker run -d --name lab-routing-osrm-mld --network lab-routing-net -v lab-routing-data:/data -p 127.0.0.1:18720:5000 \
  ghcr.io/project-osrm/osrm-backend:v26.10.0-debian osrm-routed --algorithm mld /data/mld/riyadh.osrm
docker run -d --name lab-routing-osrm-ch --network lab-routing-net -v lab-routing-data:/data -p 127.0.0.1:18721:5000 \
  ghcr.io/project-osrm/osrm-backend:v26.10.0-debian osrm-routed --algorithm ch /data/ch/riyadh.osrm
docker stats --no-stream --format '{{.Name}} {{.MemUsage}}' lab-routing-osrm-mld lab-routing-osrm-ch
lab-routing-osrm-mld 100.2MiB / 31.29GiB
lab-routing-osrm-ch 103.5MiB / 31.29GiB

أي أن خادم المدينة كاملة يحتاج قرابة 100 ميغابايت فقط بعد التجهيز. والمسار الذي نختبره من قلعة المصمك عند الإحداثية 24.6313 و46.7133، إلى مبنى الصالة 1 في مطار الملك خالد الدولي عند 24.9638 و46.7000، وأخذنا الإحداثيتين من البيانات نفسها. ولاحظ أن OSRM يأخذ الإحداثية بترتيب خط الطول أولاً ثم خط العرض، عكس ما تراه في أغلب التطبيقات، ونطلب ثلاثة بدائل والخطوات، ثم نختصر المخرج بأداة jq:

curl -s "http://127.0.0.1:18720/route/v1/driving/46.7133,24.6313;46.7000,24.9638?alternatives=3&steps=true&overview=false" > route.json
jq -c '.code, (.waypoints[] | {name, location, distance: (.distance|round)})' route.json
jq -r '.routes[] | "\(.distance/1000*10|round/10) km, \(.duration/60*10|round/10) min: \([.legs[0].steps[] | select(.distance > 1500) | .name | select(. != "")] | reduce .[] as $n ([]; if .[-1] == $n then . else . + [$n] end) | join(" | "))"' route.json

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

"Ok"
{"name":"","location":[46.713223,24.631529],"distance":27}
{"name":"","location":[46.699996,24.963798],"distance":0}
44.1 km, 34.6 min: الملك فيصل بن عبدالعزيز | الملك سعود | طريق مكة المكرمة | الدائري الشرقي | طريق الدائري الشرقي | المطار | Airport Road
56.1 km, 40.7 min: طريق الملك فهد | طريق مكة المكرمة | طريق الملك خالد | طريق الملك سلمان
46.8 km, 35.1 min: طريق الملك فهد | الدائري الشمالي | المطار | Airport Road

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

  • أول خطوة يفعلها المحرك هي «ربط» النقطة بأقرب قطعة شارع Snapping، ونقطة المصمك ربطت بشارع بلا اسم يبعد 27 متراً عنها، وهذا طبيعي لأن القلعة نفسها ليست شارعاً، ولكنه مصدر أخطاء شائع: نقطة في وسط مجمع كبير قد ترتبط بشارع خلفي لا يصل إليه أحد.
  • أسرع مسار 44.1 كيلومتراً في 34.6 دقيقة عبر طريق مكة المكرمة والدائري الشرقي، والبديل الثالث أطول بكيلومترين ونصف وأبطأ بنصف دقيقة فقط، فإذا عرض تطبيقك البدائل للمستخدم فهو يختار بينها بمعرفته بالطريق.
  • الأسماء تأتي من الوسم name في OpenStreetMap كما هي، ولذلك ترى «الدائري الشرقي» و«طريق الدائري الشرقي» كاسمين لأجزاء من الطريق نفسه، و«Airport Road» بالإنجليزية لأن هذا الجزء ليس له اسم عربي في البيانات.

والسؤال نفسه على خادم CH أعاد 44.3 كيلومتراً في 34.6 دقيقة، و46.8 كيلومتراً في 35.7 دقيقة كبديل واحد فقط، لأن طريقتي البحث مختلفتان في اختيار البدائل وفي حسم المسارات المتساوية تقريباً، فلا تتوقع أن يعيد CH وMLD المسار نفسه حرفاً بحرف.

المسار على الخريطة

لعرض المسار نضيف صفحة route.html إلى مجلد web من مقال الخريطة، وتطلب الصفحة المسار بصيغة GeoJSON وترسمه بخط فوق الخريطة:

<!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; }
</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.70, 24.78], zoom: 10.3,
  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' }) }
});

const params = new URLSearchParams(location.search);
const mode = params.get('mode') || 'route';          // route | iso
const from = [46.7133, 24.6313];                      // Al Masmak
const to = [46.7000, 24.9638];                        // King Khalid International Airport, terminal 1
const colors = ['#1F6FEB', '#8B949E', '#B08800', '#8250DF'];

map.on('load', async () => {
  const info = document.getElementById('info');
  if (mode === 'route') {
    const url = `/osrm/route/v1/driving/${from};${to}?alternatives=3&overview=full&geometries=geojson`;
    const { routes } = await fetch(url).then(r => r.json());
    // draw the alternatives first so the fastest route stays on top
    routes.map((r, i) => ({ r, i })).reverse().forEach(({ r, i }) => {
      map.addSource(`r${i}`, { type: 'geojson', data: { type: 'Feature', geometry: r.geometry } });
      map.addLayer({ id: `r${i}`, type: 'line', source: `r${i}`, layout: { 'line-cap': 'round', 'line-join': 'round' },
        paint: { 'line-color': colors[i], 'line-width': i ? 5 : 7, 'line-opacity': i ? 0.75 : 0.95 } });
    });
    info.innerHTML = routes.map((r, i) =>
      `<div><b style="background:${colors[i]}"></b>${i ? 'بديل' : 'الأسرع'}: ${(r.distance / 1000).toFixed(1)} كم، ${Math.round(r.duration / 60)} دقيقة</div>`).join('');
    new maplibregl.Marker({ color: '#1F8A55' }).setLngLat(from).addTo(map);
    new maplibregl.Marker({ color: '#C62828' }).setLngLat(to).addTo(map);
  } else {
    const center = [46.6744, 24.7114];                // Kingdom Centre
    map.jumpTo({ center: [46.69, 24.72] });
    const body = { locations: [{ lon: center[0], lat: center[1] }], costing: 'auto', contours: [{ time: 20 }, { time: 10 }], polygons: true };
    const iso = await fetch('/valhalla/isochrone', { method: 'POST', body: JSON.stringify(body) }).then(r => r.json());
    map.addSource('iso', { type: 'geojson', data: iso });
    map.addLayer({ id: 'iso-fill', type: 'fill', source: 'iso',
      paint: { 'fill-color': ['match', ['get', 'contour'], 10, '#1F8A55', '#E3A008'], 'fill-opacity': 0.28 } });
    map.addLayer({ id: 'iso-line', type: 'line', source: 'iso',
      paint: { 'line-color': ['match', ['get', 'contour'], 10, '#1F8A55', '#B07800'], 'line-width': 2 } });
    info.innerHTML = '<div><b style="background:#1F8A55"></b>خلال 10 دقائق</div><div><b style="background:#E3A008"></b>خلال 20 دقيقة</div>';
    new maplibregl.Marker({ color: '#C62828' }).setLngLat(center).addTo(map);
  }
  window.__done = true;
});
</script>
</body>
</html>

ولأن المتصفح يطلب /osrm/ و/valhalla/ من الدومين نفسه، نضيف إلى nginx.conf من مقال الخريطة قسمين يمرران الطلب إلى المحركين، فلا نحتاج إعدادات CORS، ولا يصل الزائر إلى منفذ المحرك مباشرة:

server {
    listen 80;
    root /usr/share/nginx/html;
    types {
        text/html html;
        text/css css;
        text/javascript js mjs;
        application/json json;
        image/png png;
        application/x-protobuf pbf;
        application/vnd.pmtiles pmtiles;
    }
    location / {
        try_files $uri $uri/ =404;
    }
    # the routing engines behind the same origin, so the page needs no CORS
    location /osrm/ {
        proxy_pass http://lab-routing-osrm-mld:5000/;
    }
    location /valhalla/ {
        proxy_pass http://lab-routing-valhalla:8002/;
    }
}
docker run -d --name lab-routing-web --network lab-routing-net \
  -v "$PWD/web:/usr/share/nginx/html:ro" -v "$PWD/nginx.conf:/etc/nginx/conf.d/default.conf:ro" \
  -p 127.0.0.1:18723:80 nginx:1.29-alpine

ثم افتح http://127.0.0.1:18723/route.html، والصورة التالية هي المسار والبديلان كما ظهرت:

خريطة الرياض وعليها ثلاثة مسارات من قلعة المصمك في الجنوب بعلامة خضراء إلى مطار الملك خالد في الشمال بعلامة حمراء: الأسرع بالأزرق 44.1 كم و35 دقيقة يتجه شرقاً ثم شمالاً على الدائري الشرقي، وبديل رمادي 56.1 كم و41 دقيقة يلتف غرباً، وبديل ذهبي 46.8 كم و35 دقيقة على طريق الملك فهد ثم الدائري الشمالي، وفي الزاوية العلوية جدول الأزمنة وفي السفلية نسبة OpenStreetMap
أسرع مسار وبديلان من المصمك إلى المطار، كل شيء من سيرفرنا

مصفوفة المسافات Distance Matrix

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

curl -s "http://127.0.0.1:18720/table/v1/driving/46.6744,24.7114;46.6849,24.6906;46.7133,24.6313;46.7000,24.9638?annotations=duration,distance" > table.json
jq -r '.durations[] | map(./60|round) | @tsv' table.json
jq -r '.distances[] | map(./1000*10|round/10) | @tsv' table.json

والمخرج سوف يكون كما يلي، الجدول الأول بالدقائق والثاني بالكيلومترات، والصف هو نقطة البداية والعمود هو الهدف:

0	6	12	28
5	0	10	30
11	9	0	35
29	31	34	0

0	3.7	11.8	36.9
3.1	0	9.4	38.6
11.4	8.7	0	44.1
37.7	41.3	44.8	0

ولاحظ أن الجدول غير متماثل: من مركز المملكة إلى الفيصلية 3.7 كيلومتر في 6 دقائق، والعودة 3.1 كيلومتر في 5 دقائق، والسبب أن الشوارع ذات الاتجاه الواحد والمخارج تجعل طريق الذهاب غير طريق العودة، لذلك لا تحسب نصف الجدول وتنسخ الباقي. وحجم الجدول محدود افتراضياً بمئة نقطة، كما يظهر في الخيار --max-table-size من الأمر osrm-routed --help، فإذا احتجت أكبر من ذلك فارفع الحد بوعي، لأن الجدول يكبر بمربع عدد النقاط.

مناطق الوصول مع Valhalla

السؤال الثالث الذي يطرحه أصحاب الأعمال هو «ما المنطقة التي أصل إليها خلال 10 دقائق من فرعي؟»، والجواب شكل على الخريطة اسمه Isochrone، وOSRM لا يحسبه، لذلك نجهز Valhalla الإصدار 3.9.1 على الملف نفسه:

docker run --rm --name lab-routing-valhalla -v lab-routing-data:/data ghcr.io/valhalla/valhalla:3.9.1 bash -c '
  mkdir -p /data/valhalla/tiles && cd /data/valhalla &&
  valhalla_build_config --mjolnir-tile-dir /data/valhalla/tiles --mjolnir-tile-extract /data/valhalla/tiles.tar \
    --mjolnir-timezone /data/valhalla/tz.sqlite --mjolnir-admin /data/valhalla/admins.sqlite > valhalla.json &&
  valhalla_build_admins -c valhalla.json /data/riyadh.osm.pbf > adm.log 2>&1 &&
  ( time valhalla_build_tiles -c valhalla.json /data/riyadh.osm.pbf ) > build.log 2>&1 &&
  valhalla_build_extract -c valhalla.json -v > ex.log 2>&1;
  grep -E "build_tile_set|^real" build.log; du -sh tiles tiles.tar'
2026-10-08 23:40:13.862596804 [TIMING] src/mjolnir/util.cc::build_tile_set took 37s
real	0m37.210s
52M	tiles
52M	tiles.tar

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

  • valhalla_build_admins يبني قاعدة الحدود الإدارية من الملف نفسه، وبدونها سوف ترى في السجل التحذير Admin db … not found، ويفقد المحرك معلومات الدولة لكل طريق، مثل جهة القيادة وقواعد الدولة.
  • لم نبن قاعدة المناطق الزمنية tz.sqlite، فبقي في السجل تحذيرها، وهي لازمة إذا طلبت مسارات بوقت انطلاق محدد.
  • Valhalla لا يبني هرماً من الاختصارات مثل CH، وإنما يقسم الشبكة إلى مربعات Tiles بعدة مستويات ويحسب التكلفة عند كل طلب، كما يذكر ملف المشروع، وأقصى ذاكرة رأيناها في docker stats أثناء البناء قرابة 660 ميغابايت.

ثم نشغل الخادم ونسأله عن مسار المصمك والمطار نفسه، ثم عن منطقتي 10 و20 دقيقة حول مركز المملكة:

docker run -d --name lab-routing-valhalla --network lab-routing-net -v lab-routing-data:/data -p 127.0.0.1:18722:8002 \
  ghcr.io/valhalla/valhalla:3.9.1 valhalla_service /data/valhalla/valhalla.json 1
curl -s http://127.0.0.1:18722/route --data '{"locations":[{"lat":24.6313,"lon":46.7133},{"lat":24.9638,"lon":46.7000}],"costing":"auto","units":"kilometers"}' \
  | jq -c '.trip.summary | {length, time: (.time/60*10|round/10)}'
curl -s http://127.0.0.1:18722/isochrone --data '{"locations":[{"lat":24.7114,"lon":46.6744}],"costing":"auto","contours":[{"time":10},{"time":20}],"polygons":true}' \
  | jq -c '.features[] | {contour: .properties.contour, type: .geometry.type, points: (.geometry.coordinates[0]|length)}'
{"length":46.638,"time":28.2}
{"contour":20.0,"type":"Polygon","points":1356}
{"contour":10.0,"type":"Polygon","points":457}

وهنا ملاحظة مهمة: Valhalla قال إن المسار نفسه 46.6 كيلومتراً في 28.2 دقيقة، وOSRM قال 44.1 كيلومتراً في 34.6 دقيقة، من البيانات نفسها. والسبب أن لكل محرك ملف تكلفة مختلفاً بسرعات افتراضية مختلفة وطريقة مختلفة في تقدير الانعطاف والإشارات، فكلا الرقمين تقدير، وليس أحدهما «الصحيح». وإذا غيرت المحرك في تطبيق قائم فسوف تتغير أزمنة الوصول التي اعتاد عليها مستخدموك، فقارن قبل أن تنتقل.

وللخريطة نستخدم الصفحة نفسها بالرابط route.html?mode=iso، وهي ترسل طلب المناطق إلى /valhalla/isochrone وترسم المضلعين، والصورة التالية هي النتيجة:

خريطة وسط الرياض وعليها مضلعان حول علامة حمراء عند مركز المملكة: الأخضر للمنطقة التي يصل إليها بالسيارة خلال 10 دقائق ويمتد على طول طريق الملك فهد شمالاً وجنوباً، والأصفر الأكبر لمنطقة 20 دقيقة ويغطي أغلب الرياض وله أطراف طويلة على الطرق السريعة، وداخل المنطقة الخضراء فراغ عند حديقة الملك عبدالله لا توجد فيها طرق
ما يصل إليه بالسيارة من مركز المملكة خلال 10 دقائق و20 دقيقة على طرق فارغة

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

ويعيد Valhalla تعليمات نصية لكل خطوة، وفيه ملف لغة ar-SA، فطلبنا المسار نفسه مع "language":"ar-SA"، وهذه أول أربع تعليمات كما عادت:

Drive east.
Turn اليسار.
Turn اليمين onto الإمام تركي بن عبدالله/Imam Turki bin Abdullah Street.
Make a اليسار U-turn at الملك فيصل بن عبدالعزيز/King Faisal Bin Abdulaziz to stay on الإمام تركي بن عبدالله/Imam Turki bin Abdullah Street.

أي أن الترجمة العربية ناقصة: كلمات الاتجاه مترجمة، أما الجملة نفسها فبقيت بالإنجليزية. وعندما عددنا النصوص في ملف الترجمة ar-SA.po للإصدار 3.9.1 وجدنا 398 نصاً فارغاً من 734، أي أكثر من النصف، فإذا كان تطبيقك يحتاج تعليمات عربية فإما أن تكمل هذه الترجمة وتساهم بها في المشروع، أو تبني الجمل في تطبيقك من نوع المناورة Maneuver واسم الشارع، وسوف نعود لهذا في مقال بناء تطبيق الخرائط.

نقيس زمن الاستجابة

الملف التالي يرسل ألف مسار عشوائي داخل الرياض، بالترتيب نفسه، إلى خادمي MLD وCH، ثم مئة طلب لجدول من 25 نقطة في 25، ونشغله داخل الشبكة lab-routing-net حتى لا يدخل في القياس منفذ Docker Desktop على Windows:

// bench.mjs: the same 1000 random routes inside Riyadh against two OSRM servers, then a 25 x 25 table
const servers = { mld: 'http://lab-routing-osrm-mld:5000', ch: 'http://lab-routing-osrm-ch:5000' };
let seed = 42; const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
const pt = () => [46.55 + rnd() * 0.40, 24.55 + rnd() * 0.35].map(v => v.toFixed(5)).join(',');
const pairs = Array.from({ length: 1000 }, () => `${pt()};${pt()}`);
const many = Array.from({ length: 25 }, pt).join(';');
// 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 pct = (a, p) => a[Math.min(a.length - 1, Math.floor(a.length * p))].toFixed(1);
const time = async (urls) => {
  for (const u of urls.slice(0, 20)) await get(u); // warm up
  const ms = []; let ok = 0;
  for (const u of urls) {
    const t = performance.now();
    const j = await get(u);
    ms.push(performance.now() - t); if (j.code === 'Ok') ok++;
  }
  ms.sort((a, b) => a - b);
  return `${ok}/${urls.length} Ok, median ${pct(ms, 0.5)} ms, p95 ${pct(ms, 0.95)} ms`;
};
for (const [name, base] of Object.entries(servers)) {
  console.log(`${name} route: ${await time(pairs.map(p => `${base}/route/v1/driving/${p}?overview=false`))}`);
  console.log(`${name} table 25x25: ${await time(Array(100).fill(`${base}/table/v1/driving/${many}`))}`);
}
docker run --rm --name lab-routing-bench --network lab-routing-net -v "$PWD:/w" node:22-alpine node /w/bench.mjs

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

mld route: 1000/1000 Ok, median 9.0 ms, p95 30.7 ms
mld table 25x25: 100/100 Ok, median 145.2 ms, p95 248.0 ms
ch route: 1000/1000 Ok, median 5.1 ms, p95 21.0 ms
ch table 25x25: 100/100 Ok, median 52.7 ms, p95 95.2 ms

وفي تشغيل ثان على الجهاز نفسه كانت قيم MLD للمسار 7.8 و28.0 مللي ثانية وللجدول 193.1 و268.1، وقيم CH للمسار 6.7 و29.8 وللجدول 54.4 و90.3. وبملف مشابه أرسلنا 200 من المسارات نفسها وعشر طلبات مناطق إلى Valhalla، فكان وسيط المسار 75.2 مللي ثانية، ووسيط طلب المنطقتين 1090.8 مللي ثانية. والصورة التالية تجمع الأرقام كلها:

مخطط ثلاثة مسارات من ملف riyadh.osm.pbf بحجم 9.6 ميغابايت: OSRM مع Contraction Hierarchies بعد osrm-extract في 24.5 ثانية و542 ميغابايت ثم osrm-contract في 112 ثانية و303 ميغابايت، والمسار بوسيط 5.1 مللي ثانية والجدول 25 في 25 بوسيط 53، والسرعات الجديدة تحتاج تشغيل osrm-contract من جديد. وOSRM مع Multi-Level Dijkstra بعد partition في 7.5 ثانية وcustomize في 2.2 ثانية، والمسار بوسيط 9.0 مللي ثانية والجدول 145، والسرعات الجديدة تحتاج customize فقط في ثوان. وValhalla بعد valhalla_build_tiles في 37 ثانية وقرابة 660 ميغابايت ومربعات 52 ميغابايت، والمسار بوسيط 75 مللي ثانية لأن التكلفة تحسب عند الطلب، ومنطقتا 10 و20 دقيقة بوسيط 1.1 ثانية، وتغيير التكلفة من الطلب دون إعادة بناء
كل محرك يدفع ثمن السرعة في مكان مختلف: وقت التجهيز، أو مرونة تغيير السرعات، أو زمن السؤال

وقراءة الأرقام كما يلي: كل الطرق الثلاث سريعة بما يكفي لتطبيق يسأل عن مسار عند الطلب، والفرق يظهر في الأحمال الكبيرة. فإذا كان عملك جداول مسافات كبيرة لتوزيع الطلبات فالفرق بين 53 و145 مللي ثانية للجدول الواحد يتضاعف مع آلاف الطلبات، وهذا ما يقصده ملف OSRM بأن CH أنسب للمصفوفات الكبيرة. وإذا كانت السرعات سوف تتغير كل بضع دقائق من بيانات الزحام فإعادة osrm-contract في دقيقتين لمدينة واحدة، وأكثر بكثير لدولة، تجعل MLD الخيار الطبيعي. أما Valhalla فهو الأبطأ للمسار الواحد ولكنه الوحيد هنا الذي يحسب المناطق ويغير التكلفة من الطلب، مثل تجنب الطرق ذات الرسوم لمستخدم واحد دون إعادة بناء. وهذه أرقام مدينة واحدة على جهاز واحد، فاعتبرها للمقارنة بين المحركات وليست وعداً بأداء سيرفرك.

نكسر الإعداد: خط العرض مكان خط الطول

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

curl -s "http://127.0.0.1:18720/route/v1/driving/24.6313,46.7133;24.9638,46.7000?overview=false" \
  | jq -c '{code, distance: .routes[0].distance, snapped: [.waypoints[] | {name, location, km_away: (.distance/1000|round)}]}'
{"code":"Ok","distance":0,"snapped":[{"name":"طريق الملك خالد","location":[46.376145,25.065929],"km_away":3102},{"name":"طريق الملك خالد","location":[46.376145,25.065929],"km_away":3082}]}

الرد Ok ومسافة صفر، والسبب أن الإحداثية المقلوبة تقع في مكان آخر من العالم، فربطها المحرك بأقرب شارع في بياناته وهو على بعد أكثر من 3,000 كيلومتر، وربط النقطتين بالشارع نفسه فخرج المسار صفراً. وتطبيق لا يفحص الحقل distance في waypoints سوف يعرض للمستخدم «0 دقيقة» دون أي خطأ. والحل أن تحدد أقصى مسافة للربط بالخيار radiuses بالأمتار:

curl -s "http://127.0.0.1:18720/route/v1/driving/24.6313,46.7133;24.9638,46.7000?overview=false&radiuses=100;100"
{"message":"Could not find a matching segment for coordinate 0","code":"NoSegment"}

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

ما لا يعرفه المحرك: الزحام

لاحظ أن أسرع مسار إلى المطار 44.1 كيلومتراً في 34.6 دقيقة، أي بمتوسط قرابة 76 كيلومتراً في الساعة من وسط الرياض القديم إلى المطار، وهذا ممكن فجر يوم الجمعة، أما في الساعة الثامنة صباحاً فلن يصدقه أحد يسكن المدينة. والسبب أن كل ما رأيناه في هذا المقال يحسب بسرعات الطريق الحر Free-Flow Speed، أي السرعة الافتراضية لنوع الطريق أو حده الأقصى مضروباً في 0.8، دون أي معرفة بالزحام أو الحوادث أو الإغلاقات أو ساعة اليوم. وتطبيقات مثل Google Maps لا تختلف عن محركنا في الخوارزمية بقدر ما تختلف في هذه البيانات: من أين تعرف أن هذا الشارع بطيء الآن؟ وهذا موضوع المقال التالي كيف تعرف الخرائط الزحام وسرعة الطريق؟، وفيه نغذي OSRM نفسه بسرعات مقاسة ونرى زمن الوصول يتغير.

من المختبر إلى الإنتاج

  • لا تنشر منفذ المحرك: OSRM وValhalla لا يوجد فيهما تسجيل دخول ولا حد للطلبات، فضعهما خلف Reverse Proxy كما فعلنا بالمسارين /osrm/ و/valhalla/، أو خلف ال API الخاص بتطبيقك، وحدد الخدمات المسموحة، فخدمة الجدول وحدها تكفي لإشغال المعالج بطلبات كبيرة. وقد شرحنا ذلك في تطبيقك خلف Reverse Proxy.
  • الذاكرة تكبر مع المنطقة: مدينة واحدة احتاجت نصف جيجابايت عند التجهيز ومئة ميغابايت عند التشغيل، ودولة كاملة أو الخليج يحتاج أضعاف ذلك، فجهز على سيرفر أكبر إن احتجت ثم انقل الملفات الجاهزة إلى سيرفر التشغيل.
  • التحديث: Geofabrik تنشر ملف الخليج يومياً، والمحرك لا يتحدث وحده، فأعد التجهيز في مجلد جديد ثم بدل الخادم، ولا تجهز فوق الملفات التي يقرأ منها خادم يعمل.
  • نوع المركبة: كل Profile في OSRM يحتاج تجهيزاً وخادماً منفصلين، مثل foot.lua للمشاة، أما Valhalla فيغير نوع المركبة بالحقل costing في الطلب نفسه.
  • النسبة: المسارات مبنية على بيانات OpenStreetMap برخصة ODbL، فاعرض «© OpenStreetMap» على الخريطة التي تعرض المسار كما في مقال الخريطة.

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

نحذف ال Containers بالاسم، ثم الشبكة وال Volume الذي فيه البيانات والملفات المجهزة، ثم ال Images إذا لم تعد تحتاجها:

docker rm -f lab-routing-osrm-mld lab-routing-osrm-ch lab-routing-valhalla lab-routing-web
docker network rm lab-routing-net
docker volume rm lab-routing-data
docker image rm ghcr.io/project-osrm/osrm-backend:v26.10.0-debian ghcr.io/valhalla/valhalla:3.9.1

تدريب عملي

  • غير speed_reduction في نسخة من car.lua إلى 1.0، وجهز من جديد، وقارن زمن مسار المصمك والمطار بما رأيناه هنا.
  • اطلب من Valhalla منطقة 15 دقيقة حول فرعك بالحقل "costing":"pedestrian" بدل auto، وقارن الشكل بمنطقة السيارة.

الخلاصة

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

  • محرك المسارات يحول الشوارع إلى رسم بياني موجه، وزن كل رابط فيه هو الطول مقسوماً على السرعة، والانعطاف له ثمن، والشارع ذو الاتجاه الواحد رابط واحد.
  • السرعة من الوسم maxspeed مضروباً في 0.8 في OSRM، أو من القيمة الافتراضية لنوع الطريق، وفي بيانات الرياض 11% فقط من طرق السيارات عليها maxspeed.
  • Dijkstra صحيحة ولكنها تزور المدينة كلها، وCH وMLD يجيبان في أجزاء من الثانية بعد تجهيز مسبق، وMLD تسمح بتحديث السرعات في ثوان.
  • في المختبر: المصمك إلى المطار 44.1 كيلومتراً في 34.6 دقيقة مع بديلين، وجدول المسافات غير متماثل، وValhalla يرسم مناطق الوصول ويعطي للمسار نفسه 28.2 دقيقة.
  • ضع radiuses في كل طلب وافحص مسافة الربط، لأن الإحداثية المقلوبة تعيد Ok ومسافة صفر.
  • كل الأزمنة هنا لطرق فارغة، والزحام يحتاج بيانات من نوع آخر.
دليلكيف تعرف الخرائط الزحام وسرعة الطريق؟كيف تتحول نقاط GPS من الهواتف إلى سرعة لكل شارع وزمن وصول أدق، وما الذي يحمي خصوصية أصحابها.دليلكيف تبني تطبيق خرائط وملاحة مثل هدهد وGoogle Maps؟ما الذي يقف خلف كل ميزة في تطبيقات الخرائط، وكيف تبني نسخة أولى منها بأدوات مفتوحة المصدر وبيانات مرخصة بشكل صحيح.دليلخرائطك على سيرفرك مع OpenStreetMap وMapLibreكيف تعرض خريطة تفاعلية لمدينتك من سيرفرك ببيانات OpenStreetMap، بالعربية والإنجليزية ودون مفتاح API أو مزود خارجي.دليلالبحث عن العناوين Geocoding مع Nominatimكيف تحول اسم المكان إلى إحداثيات والإحداثيات إلى عنوان على سيرفرك مع Nominatim، وما الذي يجيده بالعربية وما الذي يفوته.دليلهياكل البيانات Data Structures للمبتدئينلماذا تكون نفس العملية سريعة في هيكل بيانات وبطيئة في آخر، وأين تقابل كل هيكل في السيرفرات وقواعد البيانات.دليلBig-O للمبتدئين: لماذا يبطؤ كودك عندما تكبر البياناتلماذا يعمل الكود بسرعة على مئة صف ثم يتوقف على مئة ألف، وكيف تقرأ Big-O وتكتشف الخوارزمية البطيئة في كودك.

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

  • أكتوبر 2026: كتابة المقال، وتنفيذ المختبر على OSRM v26.10.0 وValhalla 3.9.1 وبيانات Geofabrik لدول الخليج حتى 2026-10-08، مع خريطة المقال الأول وnginx 1.29 وDocker Engine 29.8.0 على Windows 11 مع Docker Desktop.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.