feat: STEM Virtual Lab, English TTS companion, dynamic textbooks and zero-demo video fallback

This commit is contained in:
Hamza-Ayed
2026-09-03 17:37:15 +03:00
parent 445a1aa0e3
commit 58c3c48f69
19 changed files with 2162 additions and 258 deletions
@@ -347,4 +347,137 @@ class CurriculumController
]);
}
}
/**
* List all available simulations for a subject or overall
* GET /api/curriculum/simulations
*/
public function listSimulations(Request $request, Response $response): void
{
$subject = $request->getQueryParams()['subject'] ?? 'physics_10';
$simDir = realpath(__DIR__ . '/../../storage/curriculum/simulations') . "/{$subject}";
$simulations = [];
if (is_dir($simDir)) {
$files = glob("{$simDir}/*.html");
foreach ($files as $f) {
$slug = basename($f, '.html');
$title = ($slug === 'vectors_lab') ? 'مختبر جمع وتحليل المتجهات التفاعلي' :
(($slug === 'motion_1d_lab') ? 'مختبر الحركة في بُعد واحد والتسارع' : $slug);
$unit = ($slug === 'vectors_lab') ? 'الوحدة 1: المتجهات والكميات' :
(($slug === 'motion_1d_lab') ? 'الوحدة 2: الحركة والقوى' : 'تجارب تفاعلية');
$simulations[] = [
'slug' => $slug,
'title' => $title,
'unit_title' => $unit,
'url' => "/api/curriculum/simulations/{$subject}/{$slug}",
'has_canvas' => true,
'watermark' => 'منصة صَقِل التعليمية الذكية © Saqel Lab',
];
}
}
$response->json([
'status' => 'success',
'data' => $simulations
]);
}
/**
* Get / Serve Interactive HTML5 Canvas Simulation
* GET /api/curriculum/simulations/{subject}/{simName}
*/
public function getSimulation(Request $request, Response $response): void
{
$subject = preg_replace('/[^a-zA-Z0-9_-]/', '', $request->getParam('subject') ?? '');
$simName = preg_replace('/[^a-zA-Z0-9_-]/', '', $request->getParam('simName') ?? '');
$simPath = realpath(__DIR__ . '/../../storage/curriculum/simulations') . "/{$subject}/{$simName}.html";
if (!file_exists($simPath)) {
// Fallback check in root simulations
$simPath = realpath(__DIR__ . '/../../storage/curriculum/simulations') . "/{$simName}.html";
}
if (!file_exists($simPath)) {
$response->status(404)->json(['status' => 'error', 'message' => 'المحاكي التفاعلي غير موجود']);
return;
}
header('Content-Type: text/html; charset=UTF-8');
header('X-Frame-Options: SAMEORIGIN');
readfile($simPath);
exit;
}
/**
* Get real document or textbook markdown content dynamically per subject
* GET /api/curriculum/document
*/
public function getDocumentContent(Request $request, Response $response): void
{
$params = $request->getQueryParams();
$file = $params['file'] ?? '';
$subject = strtolower($params['subject'] ?? '');
$type = $params['type'] ?? 'textbook';
$storage = realpath(__DIR__ . '/../../storage/curriculum');
$resolvedFile = null;
if (!empty($file) && !str_ends_with($file, '.pdf')) {
$cleanFile = ltrim($file, '/');
if (file_exists("{$storage}/{$cleanFile}")) {
$resolvedFile = "{$storage}/{$cleanFile}";
}
}
// If not resolved by exact path, resolve by subject
if (!$resolvedFile && !empty($subject)) {
if (str_contains($subject, 'english') || str_contains($subject, 'إنجليز')) {
$mds = glob("{$storage}/grade_10/english_10/semester_1/*/*.md");
if (!empty($mds)) $resolvedFile = $mds[0];
} elseif (str_contains($subject, 'physic') || str_contains($subject, 'فيزياء')) {
$mds = glob("{$storage}/grade_10/physics_10/semester_1/*/*.md");
if (!empty($mds)) $resolvedFile = $mds[1] ?? $mds[0];
} elseif (str_contains($subject, 'math') || str_contains($subject, 'رياضيات')) {
$mds = glob("{$storage}/grade_10/math_10/semester_1/*/*.md");
if (!empty($mds)) $resolvedFile = $mds[1] ?? $mds[0];
}
}
if ($resolvedFile && file_exists($resolvedFile)) {
$content = file_get_contents($resolvedFile);
$response->json([
'status' => 'success',
'file' => str_replace("{$storage}/", '', $resolvedFile),
'subject' => $subject,
'type' => $type,
'content' => $content
]);
return;
}
// Tailored subject fallback
$content = self::getDefaultSubjectDocument($subject, $type);
$response->json([
'status' => 'success',
'subject' => $subject,
'type' => $type,
'content' => $content
]);
}
private static function getDefaultSubjectDocument(string $subject, string $type): string
{
$s = strtolower($subject);
if (str_contains($s, 'english') || str_contains($s, 'إنجليز')) {
return "## Unit 01: Looking Good — Vocabulary & Reading\n\n### 1. Key Vocabulary & Word Formation\n- **Casual clothing:** Everyday informal garments (jeans, sneakers, hoodie).\n- **Traditional attire:** Cultural heritage wear (Jordanian Thobe and Keffiyeh).\n- **Subconscious influence:** The way external appearance affects cognitive confidence.\n\n### 2. Reading Text: The Power of First Impressions\nResearch shows that within the first seven seconds of meeting someone, people make subconscious judgments about character, competence, and reliability. In a famous experiment, doctors wearing clean white coats demonstrated higher diagnostic focus.\n\n### 3. Grammar Workshop: Articles (a, an, the, zero article)\n- Use **a / an** for non-specific singular countable nouns.\n- Use **the** when the listener knows which specific thing is meant.\n- Use **zero article** with plural or uncountable nouns spoken in general terms.";
}
if (str_contains($s, 'physic') || str_contains($s, 'فيزياء')) {
return "## الوحدة الأولى: المتجهات والكميات الفيزيائية\n\n### 1. التمييز بين الكميات القياسية والمتجهة\n- **الكميات القياسية:** تُحدد بالمقدار ووحدة القياس فقط (الكتلة، الزمن، الطاقة، درجة الحرارة).\n- **الكميات المتجهة:** تُحدد بالمقدار والاتجاه ونقطة التأثير (القوة، السرعة المتجهة، التسارع، الإزاحة).\n\n### 2. جمع وتحليل المتجهات بيانياً وتحليلياً\n- **المركبة الأفقية:** A_x = A cos θ\n- **المركبة الرأسية:** A_y = A sin θ\n- **المحصلة:** R = √(R_x² + R_y²)\n- **زاوية الاتجاه:** tan θ = |R_y / R_x|";
}
return "## ملخص الوحدة والنتاجات التعليمية المقررة\n\n### 1. الأهداف العامة للمادة\n- استيعاب المفاهيم والمصطلحات الأساسية وفق المنهاج الوزاري المعتمد.\n- التدرب على حل النماذج والتطبيقات العملية والأسئلة الوزارية.\n- التحقق من اكتساب المهارات من خلال بنك الأسئلة التكيفي والمختبر.";
}
}
@@ -0,0 +1,222 @@
<?php
/**
* ==============================================================================
* SAQEL ENTERPRISE (EDTECH 2.0) - AI INTERACTIVE LAB GENERATOR SERVICE
* ==============================================================================
*
* ملف: AiInteractiveLabGeneratorService.php
* الهدف المعماري:
* محرك الذكاء الاصطناعي لتوليد مختبرات ومحاكيات العلوم والرياضيات التفاعلية (HTML5 Canvas)
* 1. قراءة وتحليل ملف الماركداون للدرس أو الوحدة واستخلاص المفاهيم الفيزيائية/الكيميائية/الرياضية.
* 2. استخدام Google Gemini لبرمجة محاكي Canvas أحادي الملف (Standalone Single-File HTML5).
* 3. تضمين العلامة المائية الدائمة لحفظ حقوق المنصة (Watermark) وحماية الكود من النسخ.
* 4. حفظ المحاكي في مجلد simulations وإتاحته عبر الـ API للتطبيق والمتصفح.
*/
namespace App\Services;
use App\Core\Database;
class AiInteractiveLabGeneratorService
{
private static string $storagePath = __DIR__ . '/../../storage/curriculum';
/**
* Generate an interactive HTML5 Canvas lab from a lesson markdown file.
*
* @param string $relativeLessonPath e.g. "grade_10/physics_10/semester_1/unit_01/lesson_01.md"
* @param string $subjectSlug e.g. "physics_10"
* @param string|null $simSlug e.g. "vectors_lab" (auto-inferred if null)
* @return array
*/
public static function generateLabFromLesson(string $relativeLessonPath, string $subjectSlug = 'physics_10', ?string $simSlug = null): array
{
$fullPath = realpath(self::$storagePath) . '/' . ltrim($relativeLessonPath, '/');
if (!file_exists($fullPath)) {
throw new \InvalidArgumentException("ملف الدرس غير موجود: {$relativeLessonPath}");
}
$lessonContent = file_get_contents($fullPath);
if (empty($lessonContent)) {
throw new \RuntimeException("ملف الدرس فارغ: {$relativeLessonPath}");
}
if (!$simSlug) {
$baseName = basename($relativeLessonPath, '.md');
$simSlug = strtolower(preg_replace('/[^a-zA-Z0-9_]/', '_', $baseName)) . '_lab';
}
$apiKey = self::resolveGeminiKey();
if (!$apiKey) {
throw new \RuntimeException("مفتاح Gemini API غير مهيأ (GEMINI_API_KEY).");
}
$prompt = self::buildSimulationPrompt($lessonContent, $subjectSlug, $simSlug);
$htmlCode = self::callGemini($apiKey, $prompt);
// Sanitize code: remove markdown code fences if model returned them
$htmlCode = preg_replace('/^```(?:html)?\s*/i', '', trim($htmlCode));
$htmlCode = preg_replace('/\s*```$/', '', $htmlCode);
// Ensure target simulation directory exists
$simDir = realpath(self::$storagePath) . "/simulations/{$subjectSlug}";
if (!is_dir($simDir)) {
mkdir($simDir, 0777, true);
}
$targetFile = "{$simDir}/{$simSlug}.html";
file_put_contents($targetFile, $htmlCode);
$relativeUrl = "/api/curriculum/simulations/{$subjectSlug}/{$simSlug}";
return [
'status' => 'success',
'sim_slug' => $simSlug,
'subject' => $subjectSlug,
'file_path' => $targetFile,
'url' => $relativeUrl,
'byte_size' => strlen($htmlCode)
];
}
/**
* Build the prompt for Gemini.
*/
private static function buildSimulationPrompt(string $lessonMarkdown, string $subjectSlug, string $simSlug): string
{
return <<<PROMPT
أنت كبير مهندسي المحاكاة البصرية والتطوير التعليمي التفاعلي لمنصة "صَقِل" (Saqel EdTech 2.0).
المطلوب منك برمجة صفحة ويب تفاعلية فائقة الجودة والفخامة (Standalone Single-File HTML5 Canvas Simulation) تُجسد المفاهيم العلمية والتطبيقية الواردة في نص الدرس المرفق.
المتطلبات التصميمية والتقنية الصارمة:
1. التصميم بأسلوب كوبرتينو الفاخر الداكن (Apple Cupertino Dark Luxury):
- خلفية أنيقة جداً #06090f مع حاويات #0d111a وحدود ناعمة شبه شفافة rgba(255,255,255,0.12).
- ألوان مميزة للمتغيرات: السماوي #00f5d4، الذهبي #ffd166، الزمردي #10b981، الأزرق الأبلي #0071e3.
- خطوط واضحة: SF Pro Arabic أو النظام الافتراضي، دعم كامل للغة العربية واتجاه RTL.
2. تفاعل الـ HTML5 Canvas:
- رسم العناصر بدقة ونعومة عالية، مع أسهم موجهة، وأطوال بمقياس رسم مدروس، وشبكة بيانية إرشادية.
- تفاعل سلس عبر سلايدرات تحكم (Sliders) مع أزرار تشغيل/إيقاف/إعادة ضبط إن كانت تجربة حركية.
- قراءات وحسابات فيزيائية/رياضية لحظية تظهر أسفل الـ Canvas توضح القوانين والنتاجات.
- ويدجت "💡 فحص سقراطي مباشر" يطرح تحدياً تفكيرياً على الطالب لتجربته بالسلايدرات.
3. العلامة المائية والحماية من النسخ (Anti-Copy Protection):
- رسم علامة مائية مضمنة غير قابلة للإزالة داخل الـ Canvas نفسه:
ctx.fillText('منصة صَقِل التعليمية الذكية © Saqel Lab - محمي', 14, 24);
- منع النقر بالزر الأيمن وحظر اختصارات النسخ لمنع استخراج الكود.
4. الاستقلالية الكاملة (Self-contained):
- ملف HTML واحد متكامل يحتوي على كل CSS و JS دون أي اعتمادات خارجية غير مدعومة.
- لا تضع أي شروحات أو مقدمات خارج كود الـ HTML، أرجع كود HTML فقط.
محتوى الدرس:
```markdown
{$lessonMarkdown}
```
PROMPT;
}
/**
* Execute Gemini API Call.
*/
private static function callGemini(string $apiKey, string $prompt): string
{
$model = 'gemini-2.0-flash';
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
$payload = [
'contents' => [
[
'parts' => [
['text' => $prompt]
]
]
],
'generationConfig' => [
'temperature' => 0.2,
'maxOutputTokens' => 8192
]
];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_TIMEOUT => 90
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200 || !$response) {
// Fallback to gemini-1.5-flash
return self::callGeminiFallback($apiKey, $prompt);
}
$resData = json_decode($response, true);
$text = $resData['candidates'][0]['content']['parts'][0]['text'] ?? '';
if (empty($text)) {
throw new \RuntimeException("استجابة غير صالحة من Gemini.");
}
return $text;
}
private static function callGeminiFallback(string $apiKey, string $prompt): string
{
$model = 'gemini-1.5-flash';
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
$payload = [
'contents' => [
[
'parts' => [
['text' => $prompt]
]
]
],
'generationConfig' => [
'temperature' => 0.2,
'maxOutputTokens' => 8192
]
];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_TIMEOUT => 90
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200 || !$response) {
throw new \RuntimeException("فشل استدعاء Gemini API (HTTP {$httpCode})");
}
$resData = json_decode($response, true);
return $resData['candidates'][0]['content']['parts'][0]['text'] ?? '';
}
private static function resolveGeminiKey(): ?string
{
if (!empty($_ENV['GEMINI_API_KEY'])) return $_ENV['GEMINI_API_KEY'];
if (!empty(getenv('GEMINI_API_KEY'))) return getenv('GEMINI_API_KEY');
// Check backend/.env
$envPath = __DIR__ . '/../../.env';
if (file_exists($envPath)) {
$lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (str_starts_with(trim($line), 'GEMINI_API_KEY=')) {
return trim(substr(trim($line), 15), '"\' ');
}
}
}
return null;
}
}
+3
View File
@@ -72,6 +72,9 @@ $router->get('/api/curriculum/search', function ($request, $response) {
'data' => \App\Services\CurriculumService::searchCurriculum($q)
]);
});
$router->get('/api/curriculum/simulations', [\App\Controllers\CurriculumController::class, 'listSimulations']);
$router->get('/api/curriculum/simulations/{subject}/{simName}', [\App\Controllers\CurriculumController::class, 'getSimulation']);
$router->get('/api/curriculum/document', [\App\Controllers\CurriculumController::class, 'getDocumentContent']);
// Health & Diagnostic Routes
$router->get('/api/health', function ($request, $response) {
@@ -0,0 +1,35 @@
<?php
/**
* CLI Tool: Generate Interactive HTML5 Canvas Lab Simulation
* Usage:
* php backend/scripts/generate_interactive_lab.php --lesson=grade_10/physics_10/semester_1/unit_01/lesson_01.md --subject=physics_10 --slug=vectors_lab
*/
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../app/Core/Database.php';
require_once __DIR__ . '/../app/Services/AiInteractiveLabGeneratorService.php';
$options = getopt('', ['lesson:', 'subject:', 'slug:']);
$lesson = $options['lesson'] ?? '';
$subject = $options['subject'] ?? 'physics_10';
$slug = $options['slug'] ?? null;
if (empty($lesson)) {
echo "❌ Error: --lesson parameter is required.\n";
echo "Example: php generate_interactive_lab.php --lesson=grade_10/physics_10/semester_1/unit_01/lesson_01.md --subject=physics_10\n";
exit(1);
}
echo "🚀 Starting AI Interactive Lab Generation for: {$lesson}...\n";
try {
$res = \App\Services\AiInteractiveLabGeneratorService::generateLabFromLesson($lesson, $subject, $slug);
echo "✅ Success! Simulation generated successfully:\n";
echo " - Simulation Slug: {$res['sim_slug']}\n";
echo " - File Location: {$res['file_path']}\n";
echo " - API Endpoint: {$res['url']}\n";
echo " - Size: {$res['byte_size']} bytes\n";
} catch (\Throwable $e) {
echo "❌ Generation failed: " . $e->getMessage() . "\n";
exit(1);
}
@@ -0,0 +1,362 @@
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>مختبر صَقِل التفاعلي: الحركة في بُعد واحد والتسارع</title>
<style>
:root {
--bg: #06090f;
--surface: #0d111a;
--card: #131a26;
--border: rgba(255, 255, 255, 0.12);
--cyan: #00f5d4;
--amber: #ffd166;
--emerald: #10b981;
--crimson: #ff4d6d;
--blue: #0071e3;
--text: #f8fafc;
--text-sec: #94a3b8;
}
* { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "SF Pro Arabic", sans-serif; }
body { background: var(--bg); color: var(--text); padding: 16px; display: flex; flex-direction: column; align-items: center; min-height: 100vh; user-select: none; -webkit-user-select: none; }
.header { width: 100%; max-width: 840px; margin-bottom: 16px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); padding-bottom: 12px; }
.header h1 { font-size: 18px; font-weight: 800; color: #fff; display: flex; align-items: center; gap: 8px; }
.badge { background: rgba(0, 245, 212, 0.15); color: var(--cyan); padding: 4px 10px; border-radius: 8px; font-size: 11px; font-weight: 700; border: 1px solid rgba(0, 245, 212, 0.3); }
.container { width: 100%; max-width: 840px; display: grid; grid-template-columns: 1fr; gap: 16px; }
@media (min-width: 768px) { .container { grid-template-columns: 1.25fr 1fr; } }
.canvas-card { background: var(--surface); border: 1px solid var(--border); border-radius: 16px; padding: 12px; display: flex; flex-direction: column; align-items: center; position: relative; gap: 12px; }
canvas { background: #080c14; border-radius: 12px; border: 1px solid rgba(255,255,255,0.06); width: 100%; height: auto; touch-action: none; }
.controls-card { background: var(--surface); border: 1px solid var(--border); border-radius: 16px; padding: 18px; display: flex; flex-direction: column; gap: 14px; }
.slider-group { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 12px; }
.slider-header { display: flex; justify-content: space-between; font-size: 13px; font-weight: 700; margin-bottom: 8px; }
input[type=range] { width: 100%; height: 6px; border-radius: 3px; background: rgba(255,255,255,0.1); outline: none; -webkit-appearance: none; margin-bottom: 8px; }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: #fff; cursor: pointer; box-shadow: 0 0 8px rgba(0,0,0,0.5); }
.btn-row { display: flex; gap: 10px; }
.btn { flex: 1; padding: 10px; border-radius: 10px; border: none; font-weight: 700; font-size: 13px; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 6px; transition: 0.2s; }
.btn-play { background: var(--emerald); color: #000; }
.btn-pause { background: var(--amber); color: #000; }
.btn-reset { background: rgba(255,255,255,0.1); color: #fff; border: 1px solid var(--border); }
.math-readout { background: #04070c; border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 12px; font-family: monospace; font-size: 12px; line-height: 1.7; color: #cbd5e1; }
.math-val { color: #fff; font-weight: bold; }
.socratic-challenge { background: rgba(255, 209, 102, 0.1); border: 1px solid rgba(255, 209, 102, 0.3); border-radius: 12px; padding: 12px; font-size: 12px; line-height: 1.5; color: #fde68a; }
.socratic-challenge strong { color: var(--amber); display: block; margin-bottom: 4px; }
</style>
</head>
<body oncontextmenu="return false;">
<div class="header">
<h1><span>🏎️</span> مختبر الحركة في بُعد واحد والتسارع</h1>
<span class="badge">فيزياء 10 • الوحدة 2</span>
</div>
<div class="container">
<div class="canvas-card">
<!-- Motion Track Canvas -->
<canvas id="motionCanvas" width="450" height="200"></canvas>
<!-- Real-time v-t & x-t Graphs Canvas -->
<canvas id="graphCanvas" width="450" height="180"></canvas>
</div>
<div class="controls-card">
<div class="btn-row">
<button id="btnPlay" class="btn btn-play">تشغيل الحركة ▶</button>
<button id="btnReset" class="btn btn-reset">إعادة ضبط ↺</button>
</div>
<!-- Initial Velocity Control -->
<div class="slider-group">
<div class="slider-header" style="color:var(--cyan);">
<span>السرعة الابتدائية (v₀)</span>
<span id="labelV0">v₀ = 10 m/s</span>
</div>
<input type="range" id="v0Slider" min="-20" max="30" value="10">
</div>
<!-- Acceleration Control -->
<div class="slider-group">
<div class="slider-header" style="color:var(--amber);">
<span>التسارع الثابت (a)</span>
<span id="labelA">a = -2.0 m/s²</span>
</div>
<input type="range" id="aSlider" min="-8" max="8" step="0.5" value="-2">
</div>
<!-- Real-time Kinematic Readout -->
<div class="math-readout" id="readout">
الزمن t = 0.0 s<br>
الموقع x(t) = v₀t + ½at² = 0.0 m<br>
السرعة اللحظية v(t) = v₀ + at = 10.0 m/s<br>
التسارع a = -2.0 m/s²
</div>
<!-- Socratic Challenge -->
<div class="socratic-challenge">
<strong>💡 فحص سقراطي تطبيقي:</strong>
لاحظ نقطة السكون اللحظي (v = 0) عندما تكون السرعة الابتدائية موجبة والتسارع سالباً! متى تعكس السيارة اتجاه حركتها؟
</div>
</div>
</div>
<script>
const mCanvas = document.getElementById('motionCanvas');
const mCtx = mCanvas.getContext('2d');
const gCanvas = document.getElementById('graphCanvas');
const gCtx = gCanvas.getContext('2d');
const v0Slider = document.getElementById('v0Slider');
const aSlider = document.getElementById('aSlider');
const labelV0 = document.getElementById('labelV0');
const labelA = document.getElementById('labelA');
const readout = document.getElementById('readout');
const btnPlay = document.getElementById('btnPlay');
const btnReset = document.getElementById('btnReset');
let isRunning = false;
let t = 0;
let animId = null;
let lastTime = null;
let history = [];
function getV0() { return parseFloat(v0Slider.value); }
function getA() { return parseFloat(aSlider.value); }
function resetSim() {
isRunning = false;
t = 0;
history = [];
btnPlay.textContent = 'تشغيل الحركة ▶';
btnPlay.className = 'btn btn-play';
if (animId) cancelAnimationFrame(animId);
drawMotion();
drawGraph();
updateReadout(0, 0, getV0());
}
function updateReadout(time, x, v) {
readout.innerHTML = `
الزمن t = <span class="math-val" style="color:#00f5d4;">${time.toFixed(2)} s</span><br>
الموقع x = <span class="math-val" style="color:#10b981;">${x.toFixed(2)} m</span> (الإزاحة)<br>
السرعة اللحظية v = <span class="math-val" style="color:#00f5d4;">${v.toFixed(2)} m/s</span><br>
التسارع الثابت a = <span class="math-val" style="color:#ffd166;">${getA().toFixed(2)} m/s²</span>
`;
}
function drawMotion() {
const w = mCanvas.width;
const h = mCanvas.height;
mCtx.clearRect(0, 0, w, h);
// Draw Track Grid & Axis
mCtx.strokeStyle = 'rgba(255,255,255,0.1)';
mCtx.lineWidth = 1;
const trackY = 120;
mCtx.beginPath();
mCtx.moveTo(20, trackY);
mCtx.lineTo(w - 20, trackY);
mCtx.stroke();
// Draw Track Marks (-50m to +100m)
const originX = 140;
const scale = 2.4; // pixels per meter
for (let m = -40; m <= 120; m += 20) {
const px = originX + m * scale;
if (px >= 20 && px <= w - 20) {
mCtx.strokeStyle = (m === 0) ? '#00f5d4' : 'rgba(255,255,255,0.2)';
mCtx.lineWidth = (m === 0) ? 2 : 1;
mCtx.beginPath();
mCtx.moveTo(px, trackY - 8);
mCtx.lineTo(px, trackY + 8);
mCtx.stroke();
mCtx.fillStyle = (m === 0) ? '#00f5d4' : '#94a3b8';
mCtx.font = '10px sans-serif';
mCtx.textAlign = 'center';
mCtx.fillText(m + 'm', px, trackY + 22);
}
}
// Calculate state
const v0 = getV0();
const a = getA();
const x = v0 * t + 0.5 * a * t * t;
const v = v0 + a * t;
const carX = Math.min(Math.max(originX + x * scale, 30), w - 30);
// Draw Car (Apple-grade rounded tech pod)
mCtx.fillStyle = '#0071e3';
mCtx.beginPath();
mCtx.roundRect(carX - 22, trackY - 26, 44, 22, 6);
mCtx.fill();
mCtx.strokeStyle = '#00f5d4';
mCtx.lineWidth = 1.5;
mCtx.stroke();
// Wheels
mCtx.fillStyle = '#1e293b';
mCtx.beginPath(); mCtx.arc(carX - 12, trackY - 2, 5, 0, Math.PI * 2); mCtx.fill();
mCtx.beginPath(); mCtx.arc(carX + 12, trackY - 2, 5, 0, Math.PI * 2); mCtx.fill();
// Velocity Vector (Cyan Arrow)
if (Math.abs(v) > 0.2) {
const vLen = Math.min(Math.max(v * 2, -60), 60);
drawArrow(mCtx, carX, trackY - 35, carX + vLen, trackY - 35, '#00f5d4', 2.5);
mCtx.fillStyle = '#00f5d4';
mCtx.font = 'bold 11px sans-serif';
mCtx.textAlign = 'center';
mCtx.fillText(`v = ${v.toFixed(1)} m/s`, carX + vLen / 2, trackY - 42);
}
// Acceleration Vector (Amber Arrow)
if (Math.abs(a) > 0.1) {
const aLen = Math.min(Math.max(a * 5, -50), 50);
drawArrow(mCtx, carX, trackY + 36, carX + aLen, trackY + 36, '#ffd166', 2.5);
mCtx.fillStyle = '#ffd166';
mCtx.font = 'bold 10px sans-serif';
mCtx.textAlign = 'center';
mCtx.fillText(`a = ${a.toFixed(1)}`, carX + aLen / 2, trackY + 50);
}
// Permanent Watermark
mCtx.save();
mCtx.fillStyle = 'rgba(0, 245, 212, 0.18)';
mCtx.font = 'bold 11px sans-serif';
mCtx.textAlign = 'left';
mCtx.fillText('منصة صَقِل التعليمية الذكية © Saqel Lab - محمي', 14, 22);
mCtx.restore();
updateReadout(t, x, v);
}
function drawGraph() {
const w = gCanvas.width;
const h = gCanvas.height;
gCtx.clearRect(0, 0, w, h);
// Graph title
gCtx.fillStyle = '#94a3b8';
gCtx.font = '11px sans-serif';
gCtx.textAlign = 'right';
gCtx.fillText('منحنى السرعة - الزمن v(t) باللون السماوي', w - 14, 18);
// Axes
const originX = 40;
const originY = h / 2;
gCtx.strokeStyle = 'rgba(255,255,255,0.2)';
gCtx.lineWidth = 1;
gCtx.beginPath();
gCtx.moveTo(originX, 10);
gCtx.lineTo(originX, h - 10);
gCtx.moveTo(originX, originY);
gCtx.lineTo(w - 10, originY);
gCtx.stroke();
// Axis labels
gCtx.fillStyle = '#94a3b8';
gCtx.font = '10px sans-serif';
gCtx.textAlign = 'center';
gCtx.fillText('t (s)', w - 16, originY + 14);
gCtx.fillText('v (m/s)', originX + 2, 14);
if (history.length < 2) return;
// Plot v(t)
gCtx.strokeStyle = '#00f5d4';
gCtx.lineWidth = 2;
gCtx.beginPath();
for (let i = 0; i < history.length; i++) {
const pt = history[i];
const px = originX + pt.t * 35;
const py = originY - pt.v * 2.2;
if (i === 0) gCtx.moveTo(px, py);
else gCtx.lineTo(px, py);
}
gCtx.stroke();
}
function drawArrow(ctx, fromx, fromy, tox, toy, color, lineWidth = 2) {
const headlen = 8;
const dx = tox - fromx;
const dy = toy - fromy;
const angle = Math.atan2(dy, dx);
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = lineWidth;
ctx.beginPath();
ctx.moveTo(fromx, fromy);
ctx.lineTo(tox, toy);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(tox, toy);
ctx.lineTo(tox - headlen * Math.cos(angle - Math.PI / 6), toy - headlen * Math.sin(angle - Math.PI / 6));
ctx.lineTo(tox - headlen * Math.cos(angle + Math.PI / 6), toy - headlen * Math.sin(angle + Math.PI / 6));
ctx.closePath();
ctx.fill();
}
function loop(timestamp) {
if (!isRunning) return;
if (!lastTime) lastTime = timestamp;
const dt = Math.min((timestamp - lastTime) / 1000, 0.05);
lastTime = timestamp;
t += dt;
const v = getV0() + getA() * t;
const x = getV0() * t + 0.5 * getA() * t * t;
history.push({ t, v, x });
drawMotion();
drawGraph();
if (t >= 10 || Math.abs(x) > 150) {
isRunning = false;
btnPlay.textContent = 'إعادة التشغيل ▶';
btnPlay.className = 'btn btn-play';
return;
}
animId = requestAnimationFrame(loop);
}
btnPlay.addEventListener('click', () => {
isRunning = !isRunning;
if (isRunning) {
btnPlay.textContent = 'إيقاف مؤقت ⏸';
btnPlay.className = 'btn btn-pause';
lastTime = null;
animId = requestAnimationFrame(loop);
} else {
btnPlay.textContent = 'استئناف ▶';
btnPlay.className = 'btn btn-play';
if (animId) cancelAnimationFrame(animId);
}
});
btnReset.addEventListener('click', resetSim);
v0Slider.addEventListener('input', () => {
labelV0.textContent = `v₀ = ${v0Slider.value} m/s`;
if (!isRunning) drawMotion();
});
aSlider.addEventListener('input', () => {
labelA.textContent = `a = ${aSlider.value} m/s²`;
if (!isRunning) drawMotion();
});
// Disable Right-Click and Copy
document.addEventListener('contextmenu', e => e.preventDefault());
document.addEventListener('keydown', e => {
if (e.ctrlKey && (e.key === 'c' || e.key === 'u' || e.key === 's')) e.preventDefault();
});
resetSim();
</script>
</body>
</html>
@@ -210,17 +210,35 @@
ctx.setLineDash([]);
}
// Draw Permanent Anti-Copy Watermark
ctx.save();
ctx.fillStyle = 'rgba(0, 245, 212, 0.18)';
ctx.font = "bold 11px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
ctx.fillText('منصة صَقِل التعليمية الذكية © Saqel Lab - محمي وغير قابل للنقل', 12, h - 14);
ctx.restore();
// Vector Dot & Cross Products
const deltaAngle = Math.abs(aRad - bRad);
const dotProd = (mA * mB * Math.cos(deltaAngle)).toFixed(1);
const crossProd = (mA * mB * Math.sin(deltaAngle)).toFixed(1);
// Update Math readout
mathOutput.innerHTML = `
Ax = ${ax.toFixed(1)} N | Ay = ${ay.toFixed(1)} N<br>
Bx = ${bx.toFixed(1)} N | By = ${by.toFixed(1)} N<br>
<span style="color:#10b981;font-weight:bold;">Rx = Ax + Bx = ${rx.toFixed(1)} N</span><br>
<span style="color:#10b981;font-weight:bold;">Ry = Ay + By = ${ry.toFixed(1)} N</span><br>
<span style="color:#10b981;font-weight:bold;">Rx = Ax + Bx = ${rx.toFixed(1)} N | Ry = Ay + By = ${ry.toFixed(1)} N</span><br>
<strong>المحصلة |R| = √(${rx.toFixed(1)}² + ${ry.toFixed(1)}²) = <span class="math-val" style="color:#10b981;">${rMag.toFixed(1)} N</span></strong><br>
<strong>اتجاه المحصلة θ_R = <span class="math-val" style="color:#10b981;">${rDeg.toFixed(1)}°</span></strong>
<strong>اتجاه المحصلة θ_R = <span class="math-val" style="color:#10b981;">${rDeg.toFixed(1)}°</span></strong><br>
<span style="color:#ffd166;">الضرب القياسي (A · B) = ${dotProd} J | الضرب المتجهي |A × B| = ${crossProd} N·m</span>
`;
}
// Disable Right-Click and Copy
document.addEventListener('contextmenu', e => e.preventDefault());
document.addEventListener('keydown', e => {
if (e.ctrlKey && (e.key === 'c' || e.key === 'u' || e.key === 's')) e.preventDefault();
});
[magA, angA, magB, angB, showRes].forEach(el => el.addEventListener('input', render));
render();
</script>