feat: STEM Virtual Lab, English TTS companion, dynamic textbooks and zero-demo video fallback
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user